DEV Community

loading...

Hooked-Form v4

jovidecroock profile image Jovi De Croock ・4 min read

This is a follow up to my last post

When coming up with version 4 of Hooked-Form I reminded myself what my goals were for Hooked-Form:

  • Low bundle size
  • High out of the box performance
  • Good Developer Experience

in version 3 these were achieved in one way or another but I knew this could be better, so I took a step back and looked at what the possibilities would be.

In the first part I'm going over how Hooked-Form works in a smaller example, in the following parts I'll discuss how I attempted improving the goals for this library.

How does it work

We don't have to reinvent the wheel, the Form-Field approach used in redux-form is a very good approach and scales well. The fundamentals of the approach have been kept preserved but with the mindset of reducing the bundle size as much as possible.

Let's make a minimal example with Hooked-Form. Let's assume we have a component where you can edit your name and your friends. Our form will have an initial set of values and we can submit it.

const Wrapper = ({ children, name, friends }) => {
  const initialValues = React.useMemo(() => ({
    name: props.name,
    friends: props.friends,
  }), [name, friends]);

  return (
    <HookedForm onSubmit={console.log} initialValues={initialValues}>
      {children}
    </HookedForm>
  )
}

That's all you need, all options can be found here. The <HookedForm> will make a form tag for you under the hood and bind the onSubmit on it. You might think but what if I want to pass in extra properties? Well any property passed that isn't an option for HookedForm will be bound to the form tag, this allows you to supply for instance a className.

Let's make a TextField so we can alter our name in the form.

const TextField = ({ fieldId }) => {
  const [{ onChange }, { value }] = useField(fieldId);
  return <input onChange={e => onChange(e.target.value)} value={value} />
}

useField contains more like onBlur, ... To manage the state of a field. The field does not make any assumptions whether you are on a web environment so it can be used in react-native, ...

If we want to hook up our name we just have to do <TextField fieldId="name" /> and we're good to go!

Read more about this hook here

If we want to manage our friends field we have the useFieldArray hook at our disposal.

const Friends = () => {
  const [{ add }, { value: friends }] = useFieldArray('friends');
  return (
    <React.Fragment>
      {friends.map((friend, index) => (
        <div>
          <TextField fieldId={`friends[${i}].name`} />
          <button onClick={() => remove(i)}>Unfriend</button>
        </div>
      )}
      <button onClick={() => add({ id: friends.length })}>Add friend</button>
    </React.Fragment>
  )
}

Read more about this hook here

All of this should have you set up to manage your friends and your own name, you can see this example in action here.

Developer Experience

We have a pretty well-known approach to this, the Form-Field method for controlled fields, this method works very well and feels very intuitive. We control our state in a central place Form and make it available for all others through a React.contextProvider. A field can opt-in to a certain field and hook in to the errors, ... for this specific field.

I realized that in some cases you would like to react to changes in another field and adapt the current or possible values to that. Before v4 this would have to be done by adding another useField that listened on that field or even a useFormConnect which listens to the whole form-state and manually check everything.
Thankfully in v4 we have a solution to that and it's called useSpy.

You can read more about useFormConnect here.

Let's look at an example:

import { useField, useSpy } from 'hooked-form';

const optionsForAMinor = [...];
const regularOptions = [...];

const MySelect = () => {
  const [options, setOptions] = useState(optionsForAMinor);
  const [{ setFieldValue }, { value }] = useField('selectField');

  useSpy('age', (newAge) => {
    if (newAge >= 18) {
      setOptions(regularOptions);
    } else {
      setOptions(optionsForAMinor);
    }
  });

  return <Select options={options} value={value} onChange={setFieldValue} />
}

Every time our age changes we can change the options without having to mix multiple useField hooks in one field.

You can read more about the hook here

Size + Performance

Before this when a value changed the Provider would check what hooks would need to be updated and did that from the Provider which in the newer React version will trigger a console.warn saying that a parent can't update a child.

This made me reconsider how we handle propagating updates to components, we use the calculateChangedBits provided in a React.createContext to say we never want to handle rerenders so the value for this becomes () => 0. If you are not familiar with this API read more here.

This means that an update to the context value would never trigger any renders, this is not yet what we want but it improves performance since in normal context cases it will trigger a render on every useContext even if the changed part is not relevant for them.

The next step here would be to make a small event-emitter that would register on every field. We have a "subject" we can listen to on every field in the form of a fieldId, this should be more than sufficient.
Every useField will register itself to the emitter with the fieldId provided in arguments. When a change is triggered in errors, ... It will look at the changed parts and emit the relevant fieldIds causing a render on those hooks.

This compact emitter resulted in a reduction of 200Bytes in size.

Concluding

I hope I succeeded at improving the Developer Experience, the performance and size parts seem to have improved to me.

If you like the library don't forget to ⭐️ the repository, that means a lot!

Let me know what you think in the comments or tweet me!

Bonus example with useSpy: https://codesandbox.io/s/admiring-vaughan-u2lzt

Discussion

pic
Editor guide
Collapse
alfredosalzillo profile image
Alfredo Salzillo

There is an error in the first code snippet

const Wrapper = ({ children, name, friends }) => {
  const initialValues = React.useMemo(() => ({
    name: props.name, // <- should be 'name,'
    friends: props.friends, // <- should be 'friends,'
  }, [name, friends]);

  return (
    <HookedForm onSubmit={console.log} initialValues={initialValues}>
      {children}
    </HookedForm>
  )
}
Collapse
jovidecroock profile image
Jovi De Croock Author

Yes, thank you it should be:

const Wrapper = ({ children, name, friends }) => {
  const initialValues = React.useMemo(() => ({
    name: props.name, // <- should be 'name,'
    friends: props.friends, // <- should be 'friends,'
  }), [name, friends]);

Thank you so much for bringing that to my attention!