Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate Formik State and API Surface #2931

Closed
wants to merge 73 commits into from

Conversation

jawnrom
Copy link

@jawnrom jawnrom commented Nov 25, 2020

Replaces #2828

Creates a @formik/core package which contains the central Formik API useFormikCore, then creates a new Ref-loving version of Formik which uses that API. Isolating the Core API will allow us to experiment with switching between useContextSelector, useMutableSource, refs, etc. It will also let us unit test the individual bits of Formik instead of using integration tests as unit tests.

Up next:

  • Move tests to @formik/core using the new selectors.

The new Core API @formik/core

The new Formik Core API is being used in the background by all Formik wrappers. This Formik Core API is meant to be separate from the actual React bindings (though it still has a dependency on React, for now), and be interchangeable between different implementations (useMutableSource, useContextSelector, etc). It takes four parameters, getState, dispatch, formikProps and isMounted, and from only those four parameters, it generates the following API surface.

return {
  handleBlur,
  handleChange,
  handleReset,
  handleSubmit,
  resetForm,
  setErrors,
  setFormikState,
  setFieldTouched,
  setFieldValue,
  setFieldError,
  setStatus,
  setSubmitting,
  setTouched,
  setValues,
  submitForm,
  validateFormWithLowPriority,
  validateFormWithHighPriority,
  validateField,
  unregisterField,
  registerField,
  getFieldProps,
  getFieldMeta,
  getFieldHelpers,
};

Anywhere the API needs to get a value from Formik's state, it calls getState, and anytime it needs to dispatch an update, it calls dispatch. This is a redux-like inversion of control.

The new implementation framework

Any implementation of the Formik API will expose the FormikCore API as well as the following helpers:

return {
  ...formikCoreApi,
  getState,
  validateForm, // generally, formikCoreApi.validateFormWithHighPriority
  // dispatch, // not sure if we should expose ??
  // addFormEffect, // this is specific to this refs implementation
}

I've added a new Formik implementation, @formik/reducer-refs which uses a redux-like state machine to dispatch changes to its internal state (via dispatch) to React's UI state (via useState). As of right now, that implementation isn't complete, BUT! it provides a great example of how I will modify the original formik to use the new @formik/core API. Basically, Formik would just pass getState() as a callback () => state from useReducer, and the dispatch as is.

The implementation now exposes a stable useFormikApi which doesn't trigger UI updates (or it won't, when I'm done!), and useFormikContext decorates that API with state which will trigger UI updates. I hope eventually to deprecate useFormikContext in favor of const [state, api] = useFormikState(); because it is more React-like.

But how does the new Reduxy API work?!

Great question! As described #2846 , the new reduxy API works like this:

Form effects

const useFormikContext = () => {
  const formikApi = useFormikApi<Values>();
  const [formikState, setFormikState] = React.useState(formikApi.getState());
  
  React.useEffect(() => {
    // every time the reducer is updated and committed, the next useEffect will pass that state to each listener
    return formikApi.addFormEffect(setFormikState);
  }, []);

  return {
    ...formikApi,
    ...formikState,
  };
};

Field effects

const useField = <Val>(props: FieldHookConfig<Val>) => {
  const formik = useFormikApi();
  const fieldMetaRef = React.useRef(getFieldMeta<Val>(fieldName));
  const [fieldMeta, setFieldMeta] = React.useState(fieldMetaRef.current);

  const maybeUpdateFieldMeta = React.useCallback<FormEffect<Values>>((formikState) => {
    const fieldMeta = selectGetFieldMeta(() => formikState /* hint: getState */)(name);

    if (!isEqual(fieldMeta , fieldMetaRef.current)) {
      fieldMetaRef.current = meta;
      setFieldMeta(meta);
    }
  }, [name, /* never update >> */ setFieldMeta, fieldMetaRef]);

  React.useEffect(() => {
    // every time the reducer is updated and committed, the next useEffect will pass that state to us
    // and we'll slice it and update our field values
    return formik.addFormEffect(maybeUpdateFieldMeta);
  }, [maybeUpdateFieldMeta]);

  return [formik.getFieldProps(props), fieldMeta, formik.getFieldHelpers(props)];
}

The useField implementation is one example of how we can optimize things and also let users optimize things their own queries to Formik's parameters. For example, if a user only needs to re-render if isValid changes, they can do this. Consequently, this would be the exact definition of useIsValid from the new useContextSelector-based API.

const [isValid, setIsValid] = useState(formikApi.getState().isValid);
const updateIsValid = React.useCallback((formikState) => setIsValid(formikState.isValid),  []);

React.useEffect(() => {
  return addFormEffect(updateIsValid);
}, [addFormEffect, updateIsValid]);

We will also provide the full set of hooks added by v3 in the useContextSelector changes, which will use this method.

@changeset-bot
Copy link

changeset-bot bot commented Nov 25, 2020

⚠️ No Changeset found

Latest commit: e03e27b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Nov 25, 2020

@jawnrom is attempting to deploy a commit to the Formium Team on Vercel.

A member of the Team first needs to authorize it.

@johnrom
Copy link
Collaborator

johnrom commented Dec 14, 2020

Updated the description to include a heck of a lot of details on how the Core API will work, and how my experimental refs implementation will work.

@jaredpalmer

johnrom and others added 2 commits December 15, 2020 19:07
…pment app for building Formik by using tsconfig aliases and enabling recompilation via the nextjs webpack config. No more yarn link.
@johnrom
Copy link
Collaborator

johnrom commented Dec 16, 2020

I just optimized a few things and now the performance is close to v3. @jaredpalmer how did you get timing metrics for testing performance?

In my last set of changes, I rewired the /app so that it compiles the Formik packages while running, and breakpoints etc are accessible via a vs code Chrome launch task. No more yarn link. I didn't touch the tsdx build process.

To try it out, pull this PR, run npm start and navigate to localhost:3000. Or run the vscode "launch chrome" profile and set some breakpoints to test.

I added 2 pages, one that tests 500 inputs using v3 and one that tests 500 inputs using @formik/reducer-refs. I couldn't tell which was faster by feeling alone.

@johnrom
Copy link
Collaborator

johnrom commented Dec 17, 2020

Switched /app to integrate with Lerna Workspace.
Set up unit test environment for vscode.
Set up dev environment for vscode.
Wrote example unit test for @formik/core.

@johnrom

This comment has been minimized.

@johnrom
Copy link
Collaborator

johnrom commented Dec 21, 2020

Resolved typescript issue with resolutions in package.json.

@johnrom
Copy link
Collaborator

johnrom commented Jan 19, 2021

It was challenging to get TypeScript to play nicely with this system, BUT! Alas, it is done. I've simplified the Subscriptions API in to more manageable chunks, using a new useSubscriptions API and keeping the functionality in there and separate from the main formik component.

The new API is like so:

import { isEqual } from 'lodash';

const MySubscriber = () => {
  const { createSubscriber, createSelector } = useFormikApi<FormValues>();

  /**
   * Create a subscriber. Memoizing it is the most important thing you can do.
   * We might want to use a `useSubscriber` hook instead, so we can make sure these get memoized.
   * Or, we can handle the memoizing in `useFormikStateSubscription`. But we'd lose some configurability.
   */
  const subscriber = React.useMemo(
    () =>
      createSubscriber(
        createSelector(selectFieldMetaByName, [fieldName]),
        isEqual
      ),
    [createSelector, createSubscriber, fieldName]
  );

  // GEE it was hard to get `fieldMeta` automatically typed.
  const fieldMeta = useFormikStateSubscription(subscriber);
}

I think the footprint can get even smaller, but I'm pretty tired of battling typescript at this point, lol. Similar to what is discussed, batched updates just do this:

    unstable_batchedUpdates(() => {
      subscriptionsRef.current.forEach(subscription => {
        const prevState = subscription.prevStateRef.current;
        const newState = subscription.selector(state);

        if (!subscription.subscriber.comparer(prevState, newState)) {
          subscription.prevStateRef.current = newState;
          subscription.listeners.forEach(listener => listener(newState));
        }
      });
    });

I abandoned the Map since I couldn't determine a great way of establishing identity with selector, args + comparers. The refs within Formik cannot be modified during render. Open to any ideas, but for now I'm just using an array. Performance is comparable to the useContextSelector version at 500 inputs, though the initial render is somewhat more expensive since each field has to search for an existing subscription.

@johnrom johnrom mentioned this pull request Jan 20, 2021
Closed
36 tasks
@johnrom
Copy link
Collaborator

johnrom commented Jan 20, 2021

The other alternative I can think of to find existing Subscriptions is the following. Not sure exactly how performance would differ without testing.

// as exists in the PR
const subscriptions: SubscriptionsRef = useRef([]);
// complex mapping object to keep track of what selectors / comparers / args map to which subscriptions
const subscriptionMap = new Map();
subscriptionMap.push(
    selectIsSubmitting, // selector
    new Map(
      Object.is, // comparer
      {
        subscription: subscriptions[0] // for example
        nextArgs: undefined,
      }
   )
);
subscriptionMap.push(
    selectFieldMetaByName, // selector
    new Map(
      _.isEqual, // comparer
      {
        subscription: undefined, // there should be no version of this without args
        nextArg: new Map(
          "firstName", // first arg
          {
            subscription: subscriptions[1],
            nextArg: undefined, // unless there are more args passed in createSelector
          }
        )
      }
   )
);

It might not even really be faster, so I wouldn't implement this unless we really wanted to do a performance audit on this part.

…an we do `useFormikState(selector: [Selector<State, Args, Return>, ...Args] | Selector<State, Return>, comparer: Comparer<Return>`, removing the implementation details and having it automatically memoized?
johnrom and others added 9 commits January 21, 2021 11:35
Fix resetForm so it always generates a new `values` to test against i…
…n of concerns.

Change the default formik state hook to `useFormikState(selector | [selector, ...args], comparer)` with auto-memoization.
Stop exposing the useFormikSubscription hook.
`useFormikState` is not quite auto-inferrable from TypeScript.
@johnrom
Copy link
Collaborator

johnrom commented Jan 24, 2021

I've simplified the API to look like this:

useFormikState(selectIsSubmitting);
useFormikState([selectFieldMetaByName, "firstName"], compareFieldMeta);

@johnrom johnrom dismissed a stale review via e691d87 January 24, 2021 20:17
@johnrom johnrom mentioned this pull request Jan 26, 2021
@johnrom
Copy link
Collaborator

johnrom commented Jan 26, 2021

The reducer-refs API enables easy optional subscriptions, such as the <Form /> subscribing to values only when onChange is passed to it. It currently is used when <Formik /> or <Field /> is used with a render function or functional child.

This could open up an easy implementation of the age old "form-level change event".

// this one automatically subscribes to values and triggers an effect when it changes
<Form onChange={values => { console.log('changed!', values); }} />
// this one doesn't, and never re-renders
<Form />

@github-actions
Copy link
Contributor

This pull request is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 60 days

@github-actions github-actions bot added the stale label Feb 26, 2021
@johnrom johnrom removed the stale label Feb 26, 2021
@johnrom johnrom mentioned this pull request Mar 3, 2021
@johnrom
Copy link
Collaborator

johnrom commented Mar 11, 2021

closed in favor of #3089

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants