diff --git a/example/LoginApiDemo.jsx b/example/LoginApiDemo.jsx
new file mode 100644
index 0000000..6ab95b0
--- /dev/null
+++ b/example/LoginApiDemo.jsx
@@ -0,0 +1,137 @@
+import React, { useRef } from 'react';
+import { Form } from '../src/index';
+
+// Schema as provided by the user (agentx-dashboard login form)
+const loginForm = {
+ display: 'form',
+ components: [
+ {
+ id: 'eiupizq',
+ key: 'email',
+ type: 'email',
+ input: true,
+ label: 'Email',
+ inputType: 'email',
+ validate: { required: true },
+ validateOn: 'change',
+ tableView: true,
+ persistent: true,
+ labelPosition: 'top',
+ },
+ {
+ id: 'emvo7dp',
+ key: 'password',
+ type: 'password',
+ input: true,
+ label: 'Password',
+ inputType: 'text',
+ protected: true,
+ validate: { required: true },
+ validateOn: 'change',
+ persistent: true,
+ labelPosition: 'top',
+ },
+ {
+ id: 'e8j0bd',
+ key: 'html',
+ tag: 'p',
+ type: 'htmlelement',
+ input: false,
+ label: 'HTML',
+ content: "Don't remember your password?",
+ },
+ {
+ id: 'e91k1hm',
+ key: 'html1',
+ tag: 'p',
+ type: 'htmlelement',
+ input: false,
+ label: 'HTML',
+ content:
+ 'By continuing, you agree to our Terms and Conditions.',
+ },
+ {
+ id: 'em0y7pc',
+ key: 'submit',
+ type: 'button',
+ input: true,
+ label: 'Login Now',
+ theme: 'primary',
+ action: 'submit',
+ size: 'md',
+ tableView: false,
+ disableOnInvalid: false,
+ },
+ ],
+};
+
+const PREFILL = { email: 'test@test.test', password: 'test' };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// Mocked login API:
+// password === 'test' → resolves after 600ms → button shows ✓
+// anything else → rejects after 800ms → button shows ✗
+// Caller just returns the promise;
handles loader / ✓ / ✗.
+
+const login = async (submission) => {
+ const email = submission?.data?.email;
+ const password = submission?.data?.password;
+
+ if (password === 'test') {
+ await sleep(600);
+ return { ok: true, token: 'mock-jwt-token', user: { email } };
+ }
+
+ await sleep(800);
+ const err = new Error('Invalid email or password');
+ err.status = 401;
+ throw err;
+};
+
+
+export default function LoginApiDemo() {
+ const myRef = useRef(null);
+
+ // Legacy manual-emit pattern: `login` does not return a promise. It kicks
+ // off an async task and later calls `formio.emit('submitDone')` or
+ // `formio.emit('submitError')` via the ref. holds the button in
+ // its loading state until one of those fires.
+ // password === 'test' → success after 600ms (✓)
+ // anything else → failure after 800ms (✗)
+
+ // const login = (submission) => {
+ // const success = submission?.data?.password === 'test';
+ // setTimeout(() => {
+ // if (success) {
+ // myRef.current?.formio?.emit('submitDone');
+ // } else {
+ // myRef.current?.formio?.emit('submitError');
+ // }
+ // }, success ? 600 : 800);
+ // };
+
+ return (
+
+
Login API Demo (mocked)
+
+ Mocked async login. The button shows a loader while the promise is
+ pending, then ✓ on resolve or ✗ on reject.
+
+ Password test → resolves after 600ms (✓).
+
+ Any other password → rejects after 800ms with{' '}
+ Invalid email or password (✗).
+
+
+
+
+
+
+ );
+}
diff --git a/example/main.jsx b/example/main.jsx
index 005bb46..57f3f49 100644
--- a/example/main.jsx
+++ b/example/main.jsx
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { Form, FormBuilder, FormEngineProvider, Formio } from '../src/index';
import 'bootstrap/dist/css/bootstrap.css';
import '../src/themes/default.css';
+import LoginApiDemo from './LoginApiDemo';
// Suppress "Missing projectId" warnings — we use formiojs purely client-side
Formio.setProjectUrl(window.location.href);
@@ -272,6 +273,8 @@ function App() {
provider work correctly.
+
+
diff --git a/package.json b/package.json
index b2e0874..4daa64d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-formio-engine",
- "version": "2.0.2",
+ "version": "2.0.3",
"description": "Form engine with builder and renderer - formiojs-powered with React functional components",
"type": "module",
"main": "dist/form-engine.umd.js",
diff --git a/src/core/FormRenderer.jsx b/src/core/FormRenderer.jsx
index d5959fa..4312724 100644
--- a/src/core/FormRenderer.jsx
+++ b/src/core/FormRenderer.jsx
@@ -27,6 +27,118 @@ const FormRenderer = forwardRef(function FormRenderer(
const opts = { ...options };
+ // When onSubmit is a prop, route it through a beforeSubmit hook so the
+ // submit button stays in its loading state until the caller's async
+ // work resolves. Supports three patterns:
+ // 1. onSubmit returns a Promise → await it
+ // 2. onSubmit emits manually → listen once for submitDone/Error
+ // 3. onSubmit throws synchronously → treat as rejection
+ if (typeof onSubmit === 'function') {
+ const userHooks = opts.hooks || {};
+ const userBeforeSubmit = userHooks.beforeSubmit;
+
+ opts.hooks = {
+ ...userHooks,
+ beforeSubmit: (submission, next) => {
+ const runOnSubmit = () => {
+ const formio = formioRef.current;
+
+ // Schema-only forms / nosubmit never emit submitDone themselves,
+ // so we must emit it ourselves on success.
+ const needsManualDoneEmit = () =>
+ formio && (formio.nosubmit || !formio.formio);
+
+ // Defensive: formio's own button listeners can leave the spinner
+ // `` attached to the DOM when the button gets redrawn between
+ // click and completion (element.loader becomes orphaned). Force
+ // loading/disabled off on every submit button via its component
+ // ref to guarantee the spinner is cleared.
+ const resetSubmitButtons = () => {
+ if (!formio || typeof formio.everyComponent !== 'function') return;
+ formio.everyComponent((c) => {
+ if (
+ c &&
+ c.component &&
+ c.component.type === 'button' &&
+ c.component.action === 'submit'
+ ) {
+ try {
+ c.loading = false;
+ c.disabled = false;
+ } catch (_) {
+ // best-effort — ignore
+ }
+ }
+ });
+ };
+
+ let result;
+ try {
+ result = onSubmit(submission);
+ } catch (err) {
+ next(err);
+ if (formio) formio.emit('submitError', err);
+ resetSubmitButtons();
+ return;
+ }
+
+ if (result && typeof result.then === 'function') {
+ result.then(
+ (data) => {
+ next();
+ if (needsManualDoneEmit()) {
+ formio.emit('submitDone', data ?? submission);
+ }
+ resetSubmitButtons();
+ },
+ (err) => {
+ const e = err || { message: 'Submission failed' };
+ next(e);
+ if (formio) formio.emit('submitError', e);
+ resetSubmitButtons();
+ },
+ );
+ return;
+ }
+
+ // Legacy manual-emit pattern: onSubmit returned nothing, so wait
+ // for the caller to emit submitDone/submitError on the formio ref.
+ // If neither is ever emitted the button stays loading — that
+ // surfaces the caller's missing completion signal rather than
+ // hiding it.
+ if (!formio) {
+ next();
+ return;
+ }
+ const cleanup = () => {
+ formio.off('submitDone', onDone);
+ formio.off('submitError', onErr);
+ };
+ const onDone = () => {
+ cleanup();
+ next();
+ resetSubmitButtons();
+ };
+ const onErr = (err) => {
+ cleanup();
+ next(err || { message: 'Submission failed' });
+ resetSubmitButtons();
+ };
+ formio.on('submitDone', onDone);
+ formio.on('submitError', onErr);
+ };
+
+ if (typeof userBeforeSubmit === 'function') {
+ userBeforeSubmit(submission, (err) =>
+ err ? next(err) : runOnSubmit(),
+ );
+ } else {
+ runOnSubmit();
+ }
+ },
+ };
+ }
+
const FormClass = formioform || FormioForm;
const instance = new FormClass(elementRef.current, formDef, opts);
instanceRef.current = instance;
@@ -44,6 +156,11 @@ const FormRenderer = forwardRef(function FormRenderer(
// Map formio.xxx events to onXxx props
instance.onAny(function (event, ...args) {
if (event.startsWith('formio.')) {
+ // When onSubmit is a prop we already invoke it from the beforeSubmit
+ // hook above; skip the onAny dispatch to avoid calling it twice.
+ if (event === 'formio.submit' && typeof onSubmit === 'function') {
+ return;
+ }
const funcName = 'on' + event.charAt(7).toUpperCase() + event.slice(8);
const allProps = {
onSubmit, onChange, onError, onRender, onCustomEvent, onSubmitDone,