From eeb8c7465325454865717b00edf5ed886a124600 Mon Sep 17 00:00:00 2001 From: Miguel Garcia Garcia Date: Fri, 26 Sep 2025 17:30:50 +0200 Subject: [PATCH 1/2] perf(form): improve performance of editor inputs --- formule-demo/src/App.tsx | 9 +++--- src/StateSynchronizer.jsx | 16 ++++++++-- src/admin/components/EditablePreview.jsx | 39 ++++++++++++++++-------- src/admin/components/FormPreview.jsx | 8 ++++- src/forms/widgets/base/SelectWidget.jsx | 12 +++++--- src/forms/widgets/base/TextWidget.jsx | 5 ++- src/store/configureStore.js | 6 ++++ 7 files changed, 70 insertions(+), 25 deletions(-) diff --git a/formule-demo/src/App.tsx b/formule-demo/src/App.tsx index af0612d..c227f62 100644 --- a/formule-demo/src/App.tsx +++ b/formule-demo/src/App.tsx @@ -47,6 +47,7 @@ import { saveToLocalStorage, loadFromLocalStorage, AiChatFooter, + getFormuleState, } from "react-formule"; import { theme } from "./theme"; import formuleLogo from "./assets/logo.png"; @@ -350,7 +351,7 @@ const App = () => { value={JSON.stringify( formuleState?.current.uiSchema, null, - 2 + 2, )} lang="json" height="25vh" @@ -366,7 +367,7 @@ const App = () => { > Form data { const reader = new FileReader(); reader.onload = (event) => { const newSchema = JSON.parse( - event?.target?.result as string + event?.target?.result as string, ); const { schema, uiSchema } = newSchema; if (schema && uiSchema) { @@ -443,7 +444,7 @@ const App = () => { message.success("Uploaded and loaded successfully"); } else { message.error( - "Your json should include a schema and a uiSchema key" + "Your json should include a schema and a uiSchema key", ); } }; diff --git a/src/StateSynchronizer.jsx b/src/StateSynchronizer.jsx index 9af3353..6eca5a9 100644 --- a/src/StateSynchronizer.jsx +++ b/src/StateSynchronizer.jsx @@ -1,11 +1,23 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useSelector } from "react-redux"; +import { isEqual, cloneDeep } from "lodash-es"; const StateSynchronizer = ({ synchronizeState, children }) => { const state = useSelector((state) => state.schemaWizard); + const previousStateRef = useRef(); + + function stripFormData(obj) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { formData, ...rest } = obj; + return rest; + } useEffect(() => { - synchronizeState(state); + const prev = previousStateRef.current; + if (!prev || !isEqual(stripFormData(state), stripFormData(prev))) { + synchronizeState(state); + } + previousStateRef.current = cloneDeep(state); }, [state, synchronizeState]); return children; diff --git a/src/admin/components/EditablePreview.jsx b/src/admin/components/EditablePreview.jsx index a886db6..bafb310 100644 --- a/src/admin/components/EditablePreview.jsx +++ b/src/admin/components/EditablePreview.jsx @@ -1,19 +1,36 @@ -import { useContext } from "react"; +import React, { useContext, useMemo } from "react"; +import { useDispatch } from "react-redux"; +import { debounce } from "lodash-es"; import Form from "../../forms/Form"; import { shoudDisplayGuideLinePopUp } from "../utils"; import { Row, Empty, Space, Typography } from "antd"; -import { useDispatch, useSelector } from "react-redux"; -import CustomizationContext from "../../contexts/CustomizationContext"; import { updateFormData } from "../../store/schemaWizard"; -const EditablePreview = ({ hideTitle, liveValidate }) => { - const schema = useSelector((state) => state.schemaWizard.current.schema); - const uiSchema = useSelector((state) => state.schemaWizard.current.uiSchema); - const formData = useSelector((state) => state.schemaWizard.formData); +import CustomizationContext from "../../contexts/CustomizationContext"; +const EditablePreview = ({ + hideTitle, + liveValidate, + schema, + uiSchema, + formData, +}) => { + const customizationContext = useContext(CustomizationContext); const dispatch = useDispatch(); - const customizationContext = useContext(CustomizationContext); + const transformedSchema = useMemo(() => { + return customizationContext.transformSchema(schema); + }, [customizationContext, schema]); + + const debouncedDispatch = useMemo(() => { + return debounce((newFormData) => { + dispatch(updateFormData({ value: newFormData })); + }, 300); + }, [dispatch]); + + const handleFormChange = (change) => { + debouncedDispatch(change.formData); + }; return ( <> @@ -47,12 +64,10 @@ const EditablePreview = ({ hideTitle, liveValidate }) => { ) : (
- dispatch(updateFormData({ value: change.formData })) - } + onChange={handleFormChange} liveValidate={liveValidate} /> )} diff --git a/src/admin/components/FormPreview.jsx b/src/admin/components/FormPreview.jsx index 7d33a1b..082129c 100644 --- a/src/admin/components/FormPreview.jsx +++ b/src/admin/components/FormPreview.jsx @@ -48,7 +48,13 @@ const FormPreview = ({ liveValidate, hideAnchors }) => { }} > {segment === "editable" ? ( - + ) : ( state.schemaWizard.formData); + const needsFormData = suggestions && params; + const formData = useSelector((state) => + needsFormData ? state.schemaWizard.formData : null, + ); const handleChange = (nextValue) => { onChange( @@ -149,10 +151,10 @@ SelectWidget.propTypes = { autofocus: PropTypes.bool, formContext: PropTypes.object, id: PropTypes.string, - multiple: PropTypes.string, + multiple: PropTypes.bool, placeholder: PropTypes.string, readonly: PropTypes.bool, - value: PropTypes.object, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), options: PropTypes.object, onBlur: PropTypes.func, onChange: PropTypes.func, diff --git a/src/forms/widgets/base/TextWidget.jsx b/src/forms/widgets/base/TextWidget.jsx index f94d028..a547471 100644 --- a/src/forms/widgets/base/TextWidget.jsx +++ b/src/forms/widgets/base/TextWidget.jsx @@ -35,7 +35,10 @@ const TextWidget = ({ useState(false); const [apiCalling, setApiCalling] = useState(false); - const formData = useSelector((state) => state.schemaWizard.formData); + const needsFormData = autofill_from && autofill_on; + const formData = useSelector((state) => + needsFormData ? state.schemaWizard.formData : null, + ); const dispatch = useDispatch(); diff --git a/src/store/configureStore.js b/src/store/configureStore.js index 2b18730..3af02e9 100644 --- a/src/store/configureStore.js +++ b/src/store/configureStore.js @@ -19,6 +19,12 @@ const preloadedState = () => { export const persistMiddleware = ({ getState }) => { return (next) => (action) => { const result = next(action); + + // Skip formData updates for better performance since formData is not persisted + if (action.type.includes("updateFormData")) { + return result; + } + const state = getState(); const persistedData = { schemaWizard: { From 4908d52efa770f2f06ef602fead7ddb6386583ea Mon Sep 17 00:00:00 2001 From: Miguel Garcia Garcia Date: Wed, 1 Oct 2025 18:37:17 +0200 Subject: [PATCH 2/2] perf(form): moved formData to separate redux state --- README.md | 7 ++++-- formule-demo/src/App.tsx | 6 ++--- src/StateSynchronizer.jsx | 23 ++++++----------- src/admin/components/EditablePreview.jsx | 15 +++-------- src/admin/components/FormPreview.jsx | 12 ++++++--- src/exposed.tsx | 32 +++++++++++++++++------- src/forms/widgets/base/SelectWidget.jsx | 10 +++----- src/forms/widgets/base/TextWidget.jsx | 12 ++++----- src/index.ts | 1 + src/store/configureStore.js | 5 ++-- src/store/form.ts | 26 +++++++++++++++++++ src/store/schemaWizard.ts | 6 ----- src/types/index.ts | 10 ++++++-- 13 files changed, 97 insertions(+), 68 deletions(-) create mode 100644 src/store/form.ts diff --git a/README.md b/README.md index dd41bd0..d18dcd4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ It also exports the following functions: - **`initFormuleSchema`**: Inits or resets the JSONSchema. You can also load an existing schema by passing it as an argument. - **`getFormuleState`**: Formule has its own internal redux state. You can retrieve it at any moment if you so require for more advanced use cases. If you want to continuosly synchronize the Formule state in your app, you can pass a callback function to FormuleContext instead (see below), which will be called every time the form state changes. +- **`getFormState`**: Formule also has a separate state for the form data, which stores the data input into each of the fields of the form. You can also synchronize this state, but keep in mind that doing it can cause performance issues with big forms. And the following utilities: @@ -172,7 +173,7 @@ const transformErrors = (errors) => { ``` -### Syncing Formule state +### Syncing Formule / Form state If you want to run some logic in your application every time the current Formule state changes in any way (e.g. to run some action every time a new field is added to the form) you can pass a function to be called back when that happens: @@ -181,13 +182,15 @@ const handleFormuleStateChange = (newState) => { // Do something when the state changes }; - + // ... ; ``` Alternatively, you can pull the current state on demand by calling `getFormuleState` at any moment. +You can also synchronize the form state (e.g. `formData`) by provinding a callback via `syncFormState`, but beware of the performance impact on big forms and prefer calling `getFormState` instead when needed. + ### Loading form data / prefill form If you want to prefill the form with existing data, you can provide the form data to `FormuleForm`. This will fill in the corresponding fields with the information in `formData`: diff --git a/formule-demo/src/App.tsx b/formule-demo/src/App.tsx index c227f62..1464785 100644 --- a/formule-demo/src/App.tsx +++ b/formule-demo/src/App.tsx @@ -47,7 +47,7 @@ import { saveToLocalStorage, loadFromLocalStorage, AiChatFooter, - getFormuleState, + getFormState, } from "react-formule"; import { theme } from "./theme"; import formuleLogo from "./assets/logo.png"; @@ -128,7 +128,7 @@ const App = () => { }; return ( - +