Skip to content

Instantly share code, notes, and snippets.

@mithi
Last active August 7, 2022 02:31
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mithi/65d1376e5e3397cf63882e635b358ffe to your computer and use it in GitHub Desktop.
Save mithi/65d1376e5e3397cf63882e635b358ffe to your computer and use it in GitHub Desktop.
import {
useState,
useCallback,
useLayoutEffect,
useRef,
useEffect,
} from 'react';
const wait = () => new Promise((resolve) => setTimeout(resolve, 3000));
// 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
const useNextEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
useNextEffect(() => {
callbackRef.current = asyncFunction;
});
const execute = useCallback(async () => {
setStatus('pending');
setValue(null);
setError(null);
await wait();
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,
};
};
export default useAsync;
//import { SUBSCRIBE_TO_NEWSLETTER_GRAPHQL_ENDPOINT } from '../constants';
import { useCallback, useEffect, useRef, useState } from 'react';
import useAsync from './useAsync';
export type ContactFormResponse = {
id: number;
subject: string;
requester_id: string;
status: string;
updated_at: string;
created_at: string;
url: string;
};
export type ContactFormFields = {
firstName: string;
lastName: string;
subject: string;
message: string;
companyName: string;
companyLocation: string;
email: string;
};
async function submitContactForm(
fields: ContactFormFields
): Promise<ContactFormResponse> {
const sampleData = {
request: {
subject: fields.subject,
requester: {
name: `${fields.firstName} ${fields.lastName}`,
email: fields.email,
},
ticket_form_id: '360003537351',
comment: { body: fields.message },
custom_fields: [
{
id: 360049222231,
value: fields.companyLocation || '',
},
{
id: 360049170852,
value: fields.companyName || '',
},
],
},
};
const response = await window.fetch(
'https://luxor.zendesk.com/api/v2/requests.json',
{
body: JSON.stringify(sampleData),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
}
);
const jsonResponse: ContactFormResponse = await response.json();
if (response.ok && jsonResponse) {
return jsonResponse;
}
return Promise.reject(new Error('Error submitting contact form to ZenDesk'));
}
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');
});
useEffect(() => {
if (shouldSubmit) {
asyncExecute();
setShouldSubmit(false);
}
}, [shouldSubmit, asyncExecute]);
const execute = useCallback((fields: ContactFormFields) => {
fieldsRef.current = fields;
setShouldSubmit(true);
}, []);
return { execute, ...rest };
};
export default useContactForm;
import { SmSpan } from '../typography';
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { borderTwHover, inputTw } from '../twStyles';
/*
IMPORTANT: The pattern for this form is modeled after: https://epicreact.dev/improve-the-performance-of-your-react-forms
THIS IS AN UNCONTROLLED FORM FIELD
*/
function FormField({
name,
wasSubmitted,
required = false,
fullWidth = false,
placeholder = '',
Component = 'input',
type = 'text',
showLabel = true,
showError = true,
onFocus,
disabled,
}: {
name: string;
wasSubmitted: boolean;
placeholder?: string;
required?: boolean;
fullWidth?: boolean;
Component?: 'input' | 'textarea';
type?: 'text' | 'email';
showLabel?: boolean;
showError?: boolean;
onFocus?: () => void;
disabled?: boolean;
}) {
const [value, setValue] = useState<null | string>(null);
const [touched, setTouched] = useState(false);
const errorMessage = !value && required ? 'Required' : null;
const displayErrorMessage =
(touched || wasSubmitted) && showError && errorMessage;
useEffect(() => {
if (wasSubmitted) {
setTouched(false);
}
}, [wasSubmitted]);
return (
<div tw="w-full p-2 md:py-3 flex flex-col" css={!fullWidth && tw`md:w-1/2`}>
{showLabel && (
<label htmlFor={`${name}-input`}>
<span tw="text-xs uppercase tracking-widest leading-loose text-tgrey">
{name}
{required && '*'}
</span>
</label>
)}
<Component
aria-label={name}
css={[
borderTwHover,
inputTw,
Component === 'textarea' && tw`h-32`,
tw`resize[none]`,
]}
id={`${name}-input`}
name={name}
type={type}
placeholder={placeholder}
onChange={(event: { currentTarget: { value: string } }) => {
setValue(event.currentTarget.value);
}}
disabled={disabled}
value={value || ''}
onBlur={() => setTouched(true)}
onFocus={() => {
setTouched(false);
onFocus?.();
}}
required={required}
aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
/>
{displayErrorMessage && (
<div role="alert" id={`${name}-error`}>
<SmSpan>
<span tw="md:m-2 text-red-500">{errorMessage}</span>
</SmSpan>
</div>
)}
</div>
);
}
export default FormField;
import {
PageBanner,
FormField as Field,
TextArrow,
Space as Hr,
} from '../components';
import React, { useCallback, useEffect, useState } from 'react';
import 'twin.macro';
import { borderTw } from '../components/twStyles';
import { SmSpan } from 'components';
import useContactForm from 'hooks/useContactForm';
/*
IMPORTANT:
The pattern for this form is modeled after: https://epicreact.dev/improve-the-performance-of-your-react-forms
*/
function Page() {
const [wasSubmitted, setWasSubmitted] = useState(false);
const [resetKey, setResetKey] = useState(1);
const {
execute,
value,
isLoading,
error,
reset: resetContactState,
isIdle,
isSuccess,
} = useContactForm();
const resetOnChange = useCallback(() => {
if (!isIdle) {
resetContactState();
setWasSubmitted(false);
}
}, [isIdle, resetContactState]);
useEffect(() => {
if (isSuccess) {
setResetKey((k) => k + 1);
setWasSubmitted(false);
}
}, [isSuccess]);
return (
<React.Fragment>
<PageBanner title="Contact">
Let{`'`}s get in touch <br />
</PageBanner>
<p tw="text-center">
Interested in working together? Fill out the form below. Our team is
ready to help you every step of the way.
</p>
<Hr size="sm" />
<form
key={resetKey}
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const email = formData.get('email')?.toString() || '';
const companyName = formData.get('name of company')?.toString() || '';
const companyLocation =
formData.get('company location')?.toString() || '';
const firstName = formData.get('first name')?.toString() || '';
const lastName = formData.get('last name')?.toString() || '';
const subject = formData.get('subject')?.toString() || '';
const message = formData.get('message')?.toString() || '';
setWasSubmitted(true);
execute({
email,
companyName,
firstName,
lastName,
subject,
message,
companyLocation,
});
}}
tw="px-2 py-6 md:p-8 flex flex-wrap"
css={borderTw}
>
<Field
name="email"
type="email"
wasSubmitted={wasSubmitted}
required={true}
fullWidth={true}
placeholder="e.g. john@doe.com"
onFocus={resetOnChange}
disabled={isLoading}
/>
<Field
name="name of company"
wasSubmitted={wasSubmitted}
placeholder="e.g. Luxor Tech"
disabled={isLoading}
/>
<Field
name="company location"
wasSubmitted={wasSubmitted}
placeholder="e.g. Cracow"
onFocus={resetOnChange}
disabled={isLoading}
/>
<Field
name="first name"
wasSubmitted={wasSubmitted}
required={true}
placeholder="e.g. John"
onFocus={resetOnChange}
disabled={isLoading}
/>
<Field
name="last name"
wasSubmitted={wasSubmitted}
required={true}
placeholder="e.g. Doe"
onFocus={resetOnChange}
disabled={isLoading}
/>
<Field
name="subject"
wasSubmitted={wasSubmitted}
fullWidth={true}
placeholder="e.g. Collaboration Offer"
onFocus={resetOnChange}
disabled={isLoading}
/>
<Field
name="message"
Component="textarea"
wasSubmitted={wasSubmitted}
fullWidth={true}
placeholder="e.g. Hey there..."
onFocus={resetOnChange}
required={true}
disabled={isLoading}
/>
<div tw="p-4 w-full flex items-end justify-end ">
<div>
{!isLoading && (
<button type="submit" tw={'outline-none focus:outline-none'}>
<TextArrow text="Send Message" />
</button>
)}
<br />
{value && <SmSpan>Message sent!</SmSpan>}
{isLoading && <SmSpan>Sending... </SmSpan>}
{error && <SmSpan>Something went wrong. Try again later.</SmSpan>}
</div>
</div>
</form>
</React.Fragment>
);
}
export default Page;
function Field({
name,
wasSubmitted,
required = false,
disabled = false,
type = text,
onChange,
validate,
}: {
name: string;
wasSubmitted: boolean;
required?: boolean;
disabled?: boolean;
type:?: string
onFocus?: () => void;
// onTouch, 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);
}}
value={value || ''}
onBlur={() => setTouched(true)}
onFocus={() => {
setTouched(false)
onFocus?.()
}}
required={required}
aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
/>
{displayErrorMessage && (
<div role="alert" id={`${name}-error`}>
{errorMessage}
</div>
)}
</div>
);
}

Is this a good async pattern for forms?

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

1. Create a useAsync hook

// 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,
  };
};

2. Create a custom hook on top of useAsync for your needs, in this case useContactForm

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 };
};

3. Create your field component

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>
  );
}

4. Finally create your form

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!

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>
);
}
// 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 };
};
@mithi
Copy link
Author

mithi commented Nov 23, 2021

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 hook

source 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)

return <form onsubmit={e => {
  const {fields, error} = getFieldsAndValidate(e)
  setWasSubmitted(true)
  !error && execute(fields)
}}

Thanks!

@mithi
Copy link
Author

mithi commented Nov 23, 2021

Aleksey Kozin — Today at 9:32 AM
I've found that controlled input for forms in react is hard to use. Lots of edge cases. Try to use a default html form. It has support of all the features you need. Check my example

https://codesandbox.io/s/add-remove-dynamic-input-fields-forked-6z2o1?file=/src/App.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment