Repository: final-form/react-final-form
Pull request #596 (2019-08-19 18:21:39)

Lazy evaluation of form and field state
Has the added benefit of making all the state values readonly.

That should only be a breaking change for the naughtiest of users.

codecov[bot]: # [Codecov](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=h1) Report
> Merging [#596](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=desc) into [master](https://codecov.io/gh/final-form/react-final-form/commit/60434b8aab9c2cc4aa48a9e4cbc6a8dc536f51ba?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/final-form/react-final-form/pull/596/graphs/tree.svg?width=650&token=s8VUe6IxMZ&height=150&src=pr)](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #596   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          16     17    +1     
  Lines         234    248   +14     
  Branches       57     57           
=====================================
+ Hits          234    248   +14
```


| [Impacted Files](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/FormSpy.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL0Zvcm1TcHkuanM=) | `100% <ø> (ø)` | :arrow_up: |
| [src/Field.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL0ZpZWxkLmpz) | `100% <ø> (ø)` | :arrow_up: |
| [src/getters.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL2dldHRlcnMuanM=) | `100% <100%> (ø)` | |
| [src/useField.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL3VzZUZpZWxkLmpz) | `100% <100%> (ø)` | :arrow_up: |
| [src/useFormState.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL3VzZUZvcm1TdGF0ZS5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/renderComponent.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL3JlbmRlckNvbXBvbmVudC5qcw==) | `100% <100%> (ø)` | :arrow_up: |
| [src/ReactFinalForm.js](https://codecov.io/gh/final-form/react-final-form/pull/596/diff?src=pr&el=tree#diff-c3JjL1JlYWN0RmluYWxGb3JtLmpz) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=footer). Last update [60434b8...7d12161](https://codecov.io/gh/final-form/react-final-form/pull/596?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/Field.js b/src/Field.js
index 8bedc88..ebe78d8 100644
--- a/src/Field.js
+++ b/src/Field.js
@@ -54,7 +54,8 @@ const Field = ({
     return React.createElement(component, { ...field.input, children, ...rest })
   }
   return renderComponent(
-    { ...field, children, component, ...rest },
+    { children, component, ...rest },
+    field,
     `Field(${name})`
   )
 }
diff --git a/src/FormSpy.js b/src/FormSpy.js
index 123080b..ca86f34 100644
--- a/src/FormSpy.js
+++ b/src/FormSpy.js
@@ -33,9 +33,9 @@ function FormSpy<FormValues: FormValuesShape>({
   return renderComponent(
     {
       ...rest,
-      ...state,
       ...renderProps
     },
+    state,
     'FormSpy'
   )
 }
diff --git a/src/ReactFinalForm.js b/src/ReactFinalForm.js
index 732f4cd..903e192 100644
--- a/src/ReactFinalForm.js
+++ b/src/ReactFinalForm.js
@@ -23,6 +23,7 @@ import type { FormRenderProps } from './types.js.flow'
 import ReactFinalFormContext from './context'
 import useLatest from './useLatest'
 import { version } from '../package.json'
+import { addLazyFormState } from './getters'
 
 export { version }
 
@@ -177,8 +178,6 @@ function ReactFinalForm<FormValues: FormValuesShape>({
   }
 
   const renderProps: FormRenderProps<FormValues> = {
-    // assign to force Flow check
-    ...state,
     form: {
       ...form,
       reset: eventOrValues => {
@@ -192,15 +191,16 @@ function ReactFinalForm<FormValues: FormValuesShape>({
     },
     handleSubmit
   }
+  addLazyFormState(renderProps, state)
   return React.createElement(
     ReactFinalFormContext.Provider,
     { value: form },
     renderComponent(
       {
         ...rest,
-        ...renderProps,
         __versions: versions
       },
+      renderProps,
       'ReactFinalForm'
     )
   )
diff --git a/src/getters.js b/src/getters.js
new file mode 100644
index 0000000..949953e
--- /dev/null
+++ b/src/getters.js
@@ -0,0 +1,57 @@
+import type { FormState, FieldState } from 'final-form'
+
+const addLazyState = (dest: Object, state: Object, keys: string[]): void => {
+  keys.forEach(key => {
+    Object.defineProperty(dest, key, {
+      get: () => state[key]
+    })
+  })
+}
+
+export const addLazyFormState = (dest: Object, state: FormState): void =>
+  addLazyState(dest, state, [
+    'active',
+    'dirty',
+    'dirtyFields',
+    'dirtySinceLastSubmit',
+    'error',
+    'errors',
+    'hasSubmitErrors',
+    'hasValidationErrors',
+    'initialValues',
+    'invalid',
+    'modified',
+    'pristine',
+    'submitError',
+    'submitErrors',
+    'submitFailed',
+    'submitSucceeded',
+    'submitting',
+    'touched',
+    'valid',
+    'validating',
+    'values',
+    'visited'
+  ])
+
+export const addLazyFieldMetaState = (dest: Object, state: FieldState): void =>
+  addLazyState(dest, state, [
+    'active',
+    'data',
+    'dirty',
+    'dirtySinceLastSubmit',
+    'error',
+    'initial',
+    'invalid',
+    'length',
+    'modified',
+    'pristine',
+    'submitError',
+    'submitFailed',
+    'submitSucceeded',
+    'submitting',
+    'touched',
+    'valid',
+    'validating',
+    'visited'
+  ])
diff --git a/src/renderComponent.js b/src/renderComponent.js
index 29e0a4d..eaeedcd 100644
--- a/src/renderComponent.js
+++ b/src/renderComponent.js
@@ -6,19 +6,30 @@ import type { RenderableProps } from './types'
 // children render function, or component prop
 export default function renderComponent<T>(
   props: RenderableProps<T> & T,
+  lazyProps: Object,
   name: string
 ): React.Node {
   const { render, children, component, ...rest } = props
   if (component) {
-    return React.createElement(component, { ...rest, children, render })
+    return React.createElement(component, {
+      ...rest,
+      ...lazyProps,
+      children,
+      render
+    })
   }
   if (render) {
-    return render(children === undefined ? rest : { ...rest, children }) // inject children back in
+    return render(
+      children === undefined
+        ? Object.assign(lazyProps, rest)
+        : // inject children back in
+          Object.assign(lazyProps, rest, { children })
+    )
   }
   if (typeof children !== 'function') {
     throw new Error(
       `Must specify either a render prop, a render function as children, or a component prop to ${name}`
     )
   }
-  return children(rest)
+  return children(Object.assign(lazyProps, rest))
 }
diff --git a/src/types.js.flow b/src/types.js.flow
index 85574a1..4f531b4 100644
--- a/src/types.js.flow
+++ b/src/types.js.flow
@@ -46,6 +46,7 @@ export type FieldRenderProps = {
     submitting?: boolean,
     touched?: boolean,
     valid?: boolean,
+    validating?: boolean,
     visited?: boolean
   }
 }
@@ -73,7 +74,7 @@ export type FormProps<FormValues: FormValuesShape> = {
 } & Config<FormValues> &
   RenderableProps<FormRenderProps<FormValues>>
 
-export type UseFieldConfig = {
+export type UseFieldAutoConfig = {
   afterSubmit?: () => void,
   allowNull?: boolean,
   beforeSubmit?: () => void | false,
@@ -86,13 +87,16 @@ export type UseFieldConfig = {
   isEqual?: (a: any, b: any) => boolean,
   multiple?: boolean,
   parse?: (value: any, name: string) => any,
-  subscription?: FieldSubscription,
   type?: string,
   validate?: FieldValidator,
   validateFields?: string[],
   value?: any
 }
 
+export type UseFieldConfig = {
+  subscription?: FieldSubscription
+} & UseFieldAutoConfig
+
 export type FieldProps = UseFieldConfig & {
   name: string
 } & RenderableProps<FieldRenderProps>
diff --git a/src/useField.js b/src/useField.js
index 4bcaa7b..3cae080 100644
--- a/src/useField.js
+++ b/src/useField.js
@@ -12,6 +12,7 @@ import isReactNative from './isReactNative'
 import getValue from './getValue'
 import useForm from './useForm'
 import useLatest from './useLatest'
+import { addLazyFieldMetaState } from './getters'
 
 const all: FieldSubscription = fieldSubscriptionItems.reduce((result, key) => {
   result[key] = true
@@ -172,52 +173,44 @@ function useField<FormValues: FormValuesShape>(
     }, [])
   }
 
-  let { blur, change, focus, value, name: ignoreName, ...otherState } = state
-  const meta = {
-    // this is to appease the Flow gods
-    active: otherState.active,
-    data: otherState.data,
-    dirty: otherState.dirty,
-    dirtySinceLastSubmit: otherState.dirtySinceLastSubmit,
-    error: otherState.error,
-    initial: otherState.initial,
-    invalid: otherState.invalid,
-    length: otherState.length,
-    modified: otherState.modified,
-    pristine: otherState.pristine,
-    submitError: otherState.submitError,
-    submitFailed: otherState.submitFailed,
-    submitSucceeded: otherState.submitSucceeded,
-    submitting: otherState.submitting,
-    touched: otherState.touched,
-    valid: otherState.valid,
-    validating: otherState.validating,
-    visited: otherState.visited
-  }
-  if (formatOnBlur) {
-    if (component === 'input') {
-      value = defaultFormat(value, name)
-    }
-  } else {
-    value = format(value, name)
-  }
-  if (value === null && !allowNull) {
-    value = ''
-  }
-  const input: FieldInputProps = { name, value, ...handlers }
-  if (type === 'checkbox') {
-    if (_value === undefined) {
-      input.checked = !!value
-    } else {
-      input.checked = !!(Array.isArray(value) && ~value.indexOf(_value))
-      input.value = _value
-    }
-  } else if (type === 'radio') {
-    input.checked = value === _value
-    input.value = _value
-  } else if (component === 'select' && multiple) {
-    input.value = input.value || []
+  const meta = {}
+  addLazyFieldMetaState(meta, state)
+  const input: FieldInputProps = {
+    name,
+    get value() {
+      let value = state.value
+      if (formatOnBlur) {
+        if (component === 'input') {
+          value = defaultFormat(value, name)
+        }
+      } else {
+        value = format(value, name)
+      }
+      if (value === null && !allowNull) {
+        value = ''
+      }
+      if (type === 'checkbox' || type === 'radio') {
+        return _value
+      } else if (component === 'select' && multiple) {
+        return value || []
+      }
+      return value
+    },
+    get checked() {
+      if (type === 'checkbox') {
+        if (_value === undefined) {
+          return !!state.value
+        } else {
+          return !!(Array.isArray(state.value) && ~state.value.indexOf(_value))
+        }
+      } else if (type === 'radio') {
+        return state.value === _value
+      }
+      return undefined
+    },
+    ...handlers
   }
+
   if (multiple) {
     input.multiple = multiple
   }
diff --git a/src/useFormState.js b/src/useFormState.js
index 18c8212..405a379 100644
--- a/src/useFormState.js
+++ b/src/useFormState.js
@@ -4,6 +4,7 @@ import type { UseFormStateParams } from './types'
 import type { FormState, FormApi, FormValuesShape } from 'final-form'
 import { all } from './ReactFinalForm'
 import useForm from './useForm'
+import { addLazyFormState } from './getters'
 
 function useFormState<FormValues: FormValuesShape>({
   onChange,
@@ -43,7 +44,9 @@ function useFormState<FormValues: FormValuesShape>({
     // eslint-disable-next-line react-hooks/exhaustive-deps
     []
   )
-  return state
+  const lazyState = {}
+  addLazyFormState(lazyState, state)
+  return lazyState
 }
 
 export default useFormState


Test patch:
diff --git a/src/renderComponent.test.js b/src/renderComponent.test.js
index 877b57c..74a1267 100644
--- a/src/renderComponent.test.js
+++ b/src/renderComponent.test.js
@@ -10,7 +10,7 @@ describe('renderComponent', () => {
       render
     }
     const name = 'TestComponent'
-    const result = renderComponent(props, name)
+    const result = renderComponent(props, {}, name)
     expect(result.props).toEqual({ children, render })
   })
 
@@ -22,7 +22,7 @@ describe('renderComponent', () => {
       render
     }
     const name = 'TestComponent'
-    renderComponent(props, name)
+    renderComponent(props, {}, name)
     expect(render).toHaveBeenCalled()
     expect(render).toHaveBeenCalledTimes(1)
     expect(render.mock.calls[0][0].children).toBe(children)
@@ -34,8 +34,39 @@ describe('renderComponent', () => {
       children
     }
     const name = 'TestComponent'
-    expect(() => renderComponent(props, name)).toThrow(
+    expect(() => renderComponent(props, {}, name)).toThrow(
       `Must specify either a render prop, a render function as children, or a component prop to ${name}`
     )
   })
+
+  it('should not evaluate any of the keys given in the second argument', () => {
+    const children = 'some children'
+    const render = jest.fn()
+    const props = {
+      children,
+      render
+    }
+    const getA = jest.fn()
+    const getB = jest.fn()
+    const name = 'TestComponent'
+    renderComponent(
+      props,
+      {
+        get a() {
+          getA()
+          return 1
+        },
+        get b() {
+          getB()
+          return 2
+        }
+      },
+      name
+    )
+    expect(render).toHaveBeenCalled()
+    expect(render).toHaveBeenCalledTimes(1)
+    expect(render.mock.calls[0][0].children).toBe(children)
+    expect(getA).not.toHaveBeenCalled()
+    expect(getB).not.toHaveBeenCalled()
+  })
 })
diff --git a/src/useField.test.js b/src/useField.test.js
index 9be978c..1c4c0f3 100644
--- a/src/useField.test.js
+++ b/src/useField.test.js
@@ -34,6 +34,42 @@ describe('useField', () => {
     console.error.mockRestore()
   })
 
+  it('should subscribe to all by default', () => {
+    const MyFieldListener = () => {
+      const { input, meta } = useField('name')
+      expect(meta.active).toBe(false)
+      expect(meta.data).toEqual({})
+      expect(meta.dirty).toBe(false)
+      expect(meta.dirtySinceLastSubmit).toBe(false)
+      expect(meta.error).toBeUndefined()
+      expect(meta.initial).toBeUndefined()
+      expect(meta.invalid).toBe(false)
+      expect(meta.length).toBeUndefined()
+      expect(meta.modified).toBe(false)
+      expect(meta.pristine).toBe(true)
+      expect(meta.submitError).toBeUndefined()
+      expect(meta.submitFailed).toBe(false)
+      expect(meta.submitSucceeded).toBe(false)
+      expect(meta.submitting).toBe(false)
+      expect(meta.touched).toBe(false)
+      expect(meta.valid).toBe(true)
+      expect(meta.validating).toBe(false)
+      expect(meta.visited).toBe(false)
+      expect(input.value).toBe('')
+      return null
+    }
+    render(
+      <Form onSubmit={onSubmitMock}>
+        {() => (
+          <form>
+            <Field name="name" component="input" data-testid="name" />
+            <MyFieldListener />
+          </form>
+        )}
+      </Form>
+    )
+  })
+
   it('should track field state', () => {
     const spy = jest.fn()
     const MyFieldListener = () => {