I've been trying to find a good ("Pure React") pattern for building performant (minimize rerendering of all fields on the change of one field) forms that are submitted asynchronously... performant but not at the expense of readability or end-user's experience.
And I came up with this way. Can anyone tell me if they think this is not a good idea or if there a better way to approach this?
This is based on: https://epicreact.dev/improve-the-performance-of-your-react-forms
// this hooks was modelled after https://usehooks.com/useAsync/
type Status = 'idle' | 'pending' | 'success' | 'error' | 'idle';
const useAsync = <T, E = string>(asyncFunction: () => Promise<T>) => {
const [status, setStatus] = useState<Status>('idle');
const [value, setValue] = useState<T | null>(null);
const [error, setError] = useState<E | null>(null);
const reset = useCallback(() => {
setValue(null);
setError(null);
}, []);
const callbackRef = useRef(asyncFunction);
// Always keep the callback function up to date
// but DONT rerender the hook each time.
// See also: https://epicreact.dev/the-latest-ref-pattern-in-react
// when using Next (not sure if this is good?)
const useNextEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
useNextEffect(() => {
callbackRef.current = asyncFunction;
});
const execute = useCallback(async () => {
setStatus('pending');
setValue(null);
setError(null);
return callbackRef
.current()
.then((response: T) => {
setValue(response);
setStatus('success');
})
.catch((error: E) => {
console.log('ASYNC ERROR:', error);
setError(error);
setStatus('error');
});
}, []);
return {
execute,
status,
value,
error,
isSuccess: status === 'success',
isIdle: status === 'idle',
isLoading: status === 'pending',
isError: status === 'error',
reset,
};
};
const useContactForm = () => {
const fieldsRef = useRef<ContactFormFields | undefined>();
const [shouldSubmit, setShouldSubmit] = useState(false);
const { execute: asyncExecute, ...rest } = useAsync<
ContactFormResponse,
string
>(() => {
return fieldsRef.current
? submitContactForm(fieldsRef.current)
: Promise.reject('No fields provided');
});
// we need to do it this way to make sure, the ref of the field
// are populated for the callback before calling the asyncFunction
useEffect(() => {
if (shouldSubmit) {
asyncExecute();
setShouldSubmit(false);
}
}, [shouldSubmit, asyncExecute]);
const execute = useCallback((fields: ContactFormFields) => {
fieldsRef.current = fields;
setShouldSubmit(true);
}, []);
return { execute, ...rest };
};
function Field({
name,
wasSubmitted,
required = false,
disabled = false,
type = text,
onChange,
validate,
}: {
name: string;
wasSubmitted: boolean;
required?: boolean;
disabled?: boolean;
type:?: string
onChange?: () => void;
// onChange doesn't return the value, only informing that there was a change
// useful if you want to reset the form
validate?: (_val:string) => (_errorMessage: string)
}) {
const [value, setValue] = useState<null | string>(null);
const [touched, setTouched] = useState(false);
const errorMessage = validate?.(value) || required && !value && 'Required'
const displayErrorMessage =
(touched || wasSubmitted) && errorMessage;
// if was submitted state of 'touch' should reset
useEffect(() => {
if (wasSubmitted) {
setTouched(false);
}
}, [wasSubmitted]);
return (
<div>
<label htmlFor={`${name}-input`}>
{name} {required && '*'}
</label>
<input
aria-label={name}
id={`${name}-input`}
name={name}
type={type}
disabled={disabled}
placeholder={placeholder}
onChange={(event: { currentTarget: { value: string } }) => {
setValue(event.currentTarget.value);
onChange?.();
}}
value={value || ''}
onBlur={() => setTouched(true)}
onFocus={() => setTouched(false)}
required={required}
aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
/>
{displayErrorMessage && (
<div role="alert" id={`${name}-error`}>
{errorMessage}
</div>
)}
</div>
);
}
function Form() {
const [wasSubmitted, setWasSubmitted] = useState(false);
const [resetKey, setResetKey] = useState(1);
const {
execute,
value,
isLoading,
error,
reset: resetContactState,
isIdle,
isSuccess,
} = useContactForm();
// used if after success or error, the fields where touched
// so no longer display error or success message.
const resetOnChange = useCallback(() => {
if (!isIdle) {
resetContactState();
setWasSubmitted(false);
}
}, [isIdle, resetContactState]);
useEffect(() => {
if (isSuccess) {
// upon successful submission
// reset the state of the form (all fields are blank)
// if if there is an error, do not erase the form
setResetKey((k) => k + 1);
}
}, [isSuccess]);
return (
<form
novalidate
key={resetKey}
onSubmit={(event) => {
event.preventDefault();
// get your fields
const formData = new FormData(event.currentTarget);
const message = formData.get('message')?.toString();
const email = formData.get('email')?.toString();
const fields = { message, email }
// if was submitted, then pass this to Fields which
setWasSubmitted(true);
// validate here
const noErrors = validate(fields)
if(noErrors) {
// an async function
execute({ email, message });
}
}}
>
<Field
name="email"
type="email"
wasSubmitted={wasSubmitted}
required={true}
onChange={resetOnChange}
disabled={isLoading}
/>
<Field
name="message"
wasSubmitted={wasSubmitted}
onChange={resetOnChange}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
Submit
</button>
{isSuccess && "Form submission successful"}
{isError && "Something went wrong."}
{isLoading && "Please wait"}
</form>
);
}
Feedback very much welcome. Thank you so much!
Is this a good async pattern for forms?
If @kentcdodds#0001 , or other anyone can share their ideas about creating performant forms that would be extremely appreciated!
(https://gist.github.com/mithi/65d1376e5e3397cf63882e635b358ffe#file-form-approach-md)
I've been trying to find a good ("Pure React")
pattern for building performant (minimize re-rendering of all fields on the change of one field) forms that are submitted asynchronously... performant but not at the expense
of readability or end-user's experience.
The goal is performance, minimize the re-render of the whole form especially if only on field is changed. But NOT sacrificing, good end-user and developer experience.
The form should reset upon successful submission, but will not if unsuccessful. The asynchronous submission function should be abstracted in a hook.
And I came up with this way. Can anyone tell me if they think this is not a good idea or is there a better way to approach this?
This is based on: https://epicreact.dev/improve-the-performance-of-your-react-forms
1. Create a
useAsync
hooksource code with explanations: (https://gist.github.com/mithi/65d1376e5e3397cf63882e635b358ffe#file-use-async-hook-tsx)
2. Create a custom hook on top of
useAsync
for your needs, in this case{execute, status} = useContactForm
source code with explanations:
(https://gist.github.com/mithi/65d1376e5e3397cf63882e635b358ffe#file-use-contact-form-tsx)
3. Create your field component
(source code with explanation:
https://gist.github.com/mithi/65d1376e5e3397cf63882e635b358ffe#file-field-component-tsx)
4. Finally create your form
(source code with explanation:
https://gist.github.com/mithi/65d1376e5e3397cf63882e635b358ffe#file-form-tsx)
Thanks!