Skip to content

Instantly share code, notes, and snippets.

@thecodedrift
Last active May 7, 2020 16:08
Show Gist options
  • Save thecodedrift/c7e44a77be6860f3b1dd88c716cb9a65 to your computer and use it in GitHub Desktop.
Save thecodedrift/c7e44a77be6860f3b1dd88c716cb9a65 to your computer and use it in GitHub Desktop.
A small (<100 lines) useForm hook
// Copyright 2020 Aibex, Inc <[email protected]>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
* useForm hook
* Used to make React forms suck a little less, while staying as true
* to React's functional programming + hooks philosophy. Memoized results
* and using refs minimize rerendering. Because your page shouldn't
* be repainting on keypress.
*
* Heavily inspired by react-hook-form, with 99% of the overhead tossed.
*
* - If you need /nested form components/ use react-hook-form
* - If you need /React Class Components/ use Formik
* - If you just want to stop having a half dozen setState calls, welcome
*/
const useForm = () => {
const refs = useRef({});
const validators = useRef({});
const [errors, setErrors] = useState(null);
/**
* Clear all errors in the form
* @type function
*/
const clearErrors = useCallback(() => {
setErrors(null);
}, []);
/**
* Clear a single error in the form by its reference name
* @type function
* @param {String} name the reference name to clear
*/
const clearError = useCallback((name) => {
setErrors((p) => {
delete p[name];
return p;
});
}, []);
/**
* Reset the hook's internal state
* Currently, this clears the errors under the hood, but this
* api is left in case additional cleanup is ever required
* as part of clicking a button of type="reset"
*/
const reset = useCallback(() => {
setErrors(null);
}, []);
/**
* Wrap a function in our hook's submit handler. Used in a form's
* onSubmit= or a submit button's onClick= property
* <form onSubmit={handleSubmit(handlerFn)}>...</form>
* The `handlerFn` has a signature of (data = {}, event). The form's
* submit event will be blocked. If you do not want this behavior,
* pass a literal `false` as the second parameter to handleSubmit.
*
* As part of form submission, any registered validators will be
* ran. If any errors are thrown in validation, the exception's message
* is set into errors.<name>
*
* @type function
* @param {function} fn The onSubmit handler to connect to this hook
* @param {boolean} preventDefault (true) should the form's default onSubmit be stopped
* @returns {function} a function suitable for an onSubmit or onClick event
*/
const handleSubmit = useCallback((fn, preventDefault = true) => {
return async (e) => {
if (preventDefault) {
e.preventDefault();
e.stopPropagation();
}
const data = {};
let valid = true;
for (const k of Object.keys(refs.current)) {
const value = refs.current[k]();
if (validators[k]) {
try {
await validators[k](value);
} catch (e) {
setError(k, e.message);
valid = false;
}
}
data[k] = value;
}
if (valid) {
fn(data, e);
}
};
}, []);
/**
* Sets an error into the hook. Used for manually enabling an error
* @type function
* @param {string} name the reference name associated with the error
* @param {string} error the error to set
*/
const setError = useCallback((name, error) => {
setErrors((p) => {
if (p === null) {
p = {};
}
return {
...p,
[name]: error,
};
});
}, []);
/**
* Register a form component by it's React ref
* This is the most common way to connect elements using this hook. By
* default, the form element must have a name and be an HTML node whose
* value can be read via <HTMLNode>.value.
* @type function
* @param {HTMLElement || ReactRef} ref an HTML Element or React Ref via ref=
*/
const register = useCallback((ref) => {
if (!ref) return;
const name = ref.name;
refs.current[name] = () => ref.value;
}, []);
/**
* Provides a means of manual registration, when refs are unfeasible
* When register() won't work, you can manually register a name/getter
* combination. Since useForm doesn't really care where the value comes
* from, that's left up to the developer via the getter function.
*
* If no `getter` is provided, this function returns a `setter`, enabling
* you to call it when your form component changes in a meaningful way.
*
* @type function
* @param {string} name the reference name to regsiter
* @param {function || null} getter (null) the value retrieval function to use
* @returns {undefined || function} if `getter` is ommitted, returns a function to set the value
*/
const manual = useCallback((name, getter = null) => {
let val = null;
if (!getter) {
getter = () => val;
}
refs.current[name] = getter;
if (!getter) {
return (v) => (val = v);
}
}, []);
/**
* Removes a registered reference
* Refs are by default cleaned up by React, but if you (for whatver reason) need to
* remove a reference yourself, this function will delete the reference from your
* data payload. As a result, any associated validators will also be skipped.
* @type function
* @param {string} name the name of the reference to remove
*/
const unregister = useCallback((name) => {
refs.current[name] = null;
delete refs.current[name];
}, []);
/**
* Completely removes all references and validators by name
* Combines unregister() and removeValidator(), dropping all references. These
* references are normally cleaned up by React when a component unmounts, but
* if you need them, they're here.
* @type function
* @param {string} name the name of the reference to remove
*/
const remove = useCallback(
(name) => {
unregister(name);
removeValidator(name);
},
[unregister]
);
/**
* Connects a validation function for the specified name
* This adds or replaces the existing validator associated with the reference name.
* @type function
* @param {string} name the name of the reference
* @param {function} fn the validator function (may be async/await)
*/
const validate = useCallback((name, fn) => {
validators.current[name] = fn;
}, []);
/**
* Removes a validator associated with a particular reference.
* Refs are by default cleaned up by React, but if you (for whatver reason) need to
* remove a validator yourself, this function will delete the validator.
*/
const removeValidator = useCallback((name) => {
validators.current[name] = null;
delete validators.current[name];
}, []);
// destroy refs & validators on unmount
// this lets React clean things up properly
useEffect(() => {
return () => {
refs.current = {};
validators.current = {};
};
}, []);
// return the memoized paylod to only cause rerenders when errors
// surface (the absolute minimum number of rerenders possible without)
// externalizing the error handling of the hook
return useMemo(() => {
return {
errors,
clearErrors,
clearError,
handleSubmit,
setError,
register,
manual,
unregister,
remove,
validate,
removeValidator,
reset,
};
}, [errors]);
};
export { useForm };
@thecodedrift
Copy link
Author

useForm (a simple useForm hook for React)

Throw this in your hooks folder, get back to productive form making.

This isn't really meant to be a comprehensive solution to React forms. It's a hook that is simplified down to a minimum set of functions common across (most) React form pain. It uses uncontrolled inputs by default, but can be manually connected to controlled inputs if required.

  • You want to use functional components, hooks, and setState for every element is unwieldy
  • You (may) have custom components that (may or may not) use ref/innerRef properly
  • Your forms are simple enough that you don't have deeply nested components resulting in ref / callback passing headaches

Usage

const MyComponent = () => {
  const { handleSubmit, register, errors, reset } = useForm();
  const onSubmit = async (vales, e) => {
    // do what needs to be done. await-friendly
    // values.myText contains the text entered by the user
  };

  return (<form onSubmit={handleSubmit(onSubmit)}>
    {errors && (<div>Oh no! {errors.myText}</div>)}
    <input
      name="myText"
      type="text"
      required
      minLength="10"
      placeholder="type ten characters..."
      ref={register} />
    <button type="submit">Go!</button>
  </form>);
}

API

The majority of time you'll just need register/handleSubmit. If you're using controlled components, you'll want to manually register the reference with const mySetter = manual(name); and call mySetter as the value is changed.

Code above is documented.

What About...?

No. I mean, we could add more stuff, but if you're making things more complex, select a full battle-tested form library. This is a hook for the simple forms where HTML Validation (or lightweight JS validation) is sufficient, you don't have nested form components that require a Context object, and you're about one more useState() + useCallback() away from your functional component feeling needlessly large.

This hook is small enough you can have it both ways.

❤️ The OSS Team at Aibex

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