diff --git a/docs/config.json b/docs/config.json index ea14d4fd6..eaf6aec8b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -85,7 +85,10 @@ { "label": "SSR/Next.js", "to": "framework/react/guides/ssr" - + }, + { + "label": "Debugging", + "to": "framework/react/guides/debugging" } ] }, diff --git a/docs/framework/react/guides/debugging.md b/docs/framework/react/guides/debugging.md new file mode 100644 index 000000000..305b2f35e --- /dev/null +++ b/docs/framework/react/guides/debugging.md @@ -0,0 +1,17 @@ +--- +id: debugging +title: Debugging React Usage +--- + +Here's a list of common errors you might see in the console and how to fix them. + +# Changing an uncontrolled input to be controlled + +If you see this error in the console: + +``` +Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components +``` + +It's likely you forgot the `defaultValues` in your `useForm` Hook or `form.Field` component usage. This is occurring +because the input is being rendered before the form value is initialized and is therefore changing from `undefined` to `""` when a text input is made. diff --git a/docs/reference/fieldApi.md b/docs/reference/fieldApi.md index 50623ce06..e601b9343 100644 --- a/docs/reference/fieldApi.md +++ b/docs/reference/fieldApi.md @@ -164,10 +164,6 @@ A class representing the API for managing a form field. #### Properties -- ```tsx - uid: number - ``` - - A unique identifier for the field instance. - ```tsx form: FormApi ``` diff --git a/docs/reference/formApi.md b/docs/reference/formApi.md index 3b71c867c..e79ed610a 100644 --- a/docs/reference/formApi.md +++ b/docs/reference/formApi.md @@ -29,7 +29,7 @@ An object representing the options for a form. defaultValues?: TData ``` - Set initial values for your form. - + - ```tsx defaultState?: Partial> ``` @@ -61,7 +61,7 @@ An object representing the options for a form. onMount?: (values: TData, formApi: FormApi) => ValidationError ``` - Optional function that fires as soon as the component mounts. - + - ```tsx onChange?: (values: TData, formApi: FormApi) => ValidationError ``` @@ -102,17 +102,17 @@ A class representing the Form API. It handles the logic and interactions with th options: FormOptions = {} ``` - The options for the form. - + - ```tsx store: Store> ``` - A [TanStack Store instance](https://tanstack.com/store/latest/docs/reference/Store) that keeps track of the form's state. - + - ```tsx state: FormState ``` - The current state of the form. - + - ```tsx fieldInfo: Record, FieldInfo> = {} as any @@ -197,62 +197,62 @@ An object representing the current state of the form. errorMap: ValidationErrorMap ``` - The error map for the form itself. - + - ```tsx isFormValidating: boolean ``` - A boolean indicating if the form is currently validating. - + - ```tsx isFormValid: boolean ``` - A boolean indicating if the form is valid. - + - ```tsx fieldMeta: Record, FieldMeta> ``` - A record of field metadata for each field in the form. - + - ```tsx isFieldsValidating: boolean ``` - A boolean indicating if any of the form fields are currently validating. - + - ```tsx isFieldsValid: boolean ``` - A boolean indicating if all the form fields are valid. - + - ```tsx isSubmitting: boolean ``` - A boolean indicating if the form is currently submitting. - + - ```tsx isTouched: boolean ``` - A boolean indicating if any of the form fields have been touched. - + - ```tsx isSubmitted: boolean ``` - A boolean indicating if the form has been submitted. - + - ```tsx isValidating: boolean ``` - A boolean indicating if the form or any of its fields are currently validating. - + - ```tsx isValid: boolean ``` - A boolean indicating if the form and all its fields are valid. - + - ```tsx canSubmit: boolean ``` - A boolean indicating if the form can be submitted based on its current state. - + - ```tsx submissionAttempts: number ``` @@ -268,17 +268,14 @@ An object representing the current state of the form. An object representing the field information for a specific field within the form. - ```tsx - instances: Record< - string, - FieldApi< + instance: FieldApi< TFormData, any, Validator | undefined, TFormValidator - > - > + > | null ``` - - A record of field instances with unique identifiers as keys. + - An instance of the `FieldAPI`. - ```tsx validationMetaMap: Record diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 4b92d62a7..e977e9319 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -237,8 +237,6 @@ export type FieldMeta = { isValidating: boolean } -let uid = 0 - export type FieldState = { value: TData meta: FieldMeta @@ -259,7 +257,6 @@ export class FieldApi< | undefined = undefined, TData extends DeepValue = DeepValue, > { - uid: number form: FieldApiOptions< TParentData, TName, @@ -289,13 +286,6 @@ export class FieldApi< >, ) { this.form = opts.form as never - this.uid = uid++ - // Support field prefixing from FieldScope - // let fieldPrefix = '' - // if (this.form.fieldName) { - // fieldPrefix = `${this.form.fieldName}.` - // } - this.name = opts.name as never if (opts.defaultValue !== undefined) { @@ -362,7 +352,7 @@ export class FieldApi< mount = () => { const info = this.getInfo() - info.instances[this.uid] = this as never + info.instance = this as never const unsubscribe = this.form.store.subscribe(() => { this.store.batch(() => { const nextValue = this.getValue() @@ -403,13 +393,8 @@ export class FieldApi< const preserveValue = this.options.preserveValue unsubscribe() if (!preserveValue) { - delete info.instances[this.uid] this.form.deleteField(this.name) } - - if (!Object.keys(info.instances).length && !preserveValue) { - delete this.form.fieldInfo[this.name] - } } } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ae5cfab4b..abc3f8bce 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -104,15 +104,12 @@ export type FieldInfo< TFormData, TFormValidator extends Validator | undefined = undefined, > = { - instances: Record< - string, - FieldApi< - TFormData, - any, - Validator | undefined, - TFormValidator - > - > + instance: FieldApi< + TFormData, + any, + Validator | undefined, + TFormValidator + > | null validationMetaMap: Record } @@ -340,17 +337,17 @@ export class FormApi< void ( Object.values(this.fieldInfo) as FieldInfo[] ).forEach((field) => { - Object.values(field.instances).forEach((instance) => { - // Validate the field - fieldValidationPromises.push( - Promise.resolve().then(() => instance.validate(cause)), - ) - // If any fields are not touched - if (!instance.state.meta.isTouched) { - // Mark them as touched - instance.setMeta((prev) => ({ ...prev, isTouched: true })) - } - }) + if (!field.instance) return + const fieldInstance = field.instance + // Validate the field + fieldValidationPromises.push( + Promise.resolve().then(() => fieldInstance.validate(cause)), + ) + // If any fields are not touched + if (!field.instance.state.meta.isTouched) { + // Mark them as touched + field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) + } }) }) @@ -587,7 +584,7 @@ export class FormApi< ): FieldInfo => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return (this.fieldInfo[field] ||= { - instances: {}, + instance: null, validationMetaMap: { onChange: undefined, onBlur: undefined, @@ -645,6 +642,7 @@ export class FormApi< return newState }) + delete this.fieldInfo[field] } pushFieldValue = >( diff --git a/packages/form-core/src/tests/FieldApi.spec.ts b/packages/form-core/src/tests/FieldApi.spec.ts index 2e1106de8..cf0b54e12 100644 --- a/packages/form-core/src/tests/FieldApi.spec.ts +++ b/packages/form-core/src/tests/FieldApi.spec.ts @@ -602,7 +602,7 @@ describe('field api', () => { const unmount = field.mount() unmount() - expect(form.getFieldInfo(field.name).instances[field.uid]).toBeDefined() + expect(form.getFieldInfo(field.name).instance).toBeDefined() expect(form.getFieldInfo(field.name)).toBeDefined() }) @@ -624,8 +624,8 @@ describe('field api', () => { unmount() const info = form.getFieldInfo(field.name) subscription() - expect(info.instances[field.uid]).toBeUndefined() - expect(Object.keys(info.instances).length).toBe(0) + expect(info.instance).toBeNull() + expect(Object.keys(info.instance ?? {}).length).toBe(0) // Check that form store has been updated expect(callback).toHaveBeenCalledOnce() diff --git a/packages/react-form/src/tests/useField.test.tsx b/packages/react-form/src/tests/useField.test.tsx index a8e4e9f69..24b805c03 100644 --- a/packages/react-form/src/tests/useField.test.tsx +++ b/packages/react-form/src/tests/useField.test.tsx @@ -3,9 +3,9 @@ import * as React from 'react' import { render, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom' -import { createFormFactory } from '../index' +import { createFormFactory, useForm } from '../index' import { sleep } from './utils' -import type { FormApi } from '../index' +import type { FieldApi, FormApi } from '../index' const user = userEvent.setup() @@ -101,7 +101,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -143,7 +148,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -188,7 +198,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -239,7 +254,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -288,7 +308,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -346,7 +371,12 @@ describe('useField', () => { const formFactory = createFormFactory() function Comp() { - const form = formFactory.useForm() + const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( @@ -395,7 +425,12 @@ describe('useField', () => { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( { const formFactory = createFormFactory() let form: FormApi | null = null function Comp() { - form = formFactory.useForm() + form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + }) return ( { const info = form!.fieldInfo expect(Object.keys(info)).toHaveLength(0) }) + + it('should handle strict mode properly with conditional fields', async () => { + function FieldInfo({ field }: { field: FieldApi }) { + return ( + <> + {field.state.meta.touchedErrors ? ( + {field.state.meta.touchedErrors} + ) : null} + {field.state.meta.isValidating ? 'Validating...' : null} + + ) + } + + function Comp() { + const [showField, setShowField] = React.useState(true) + + const form = useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onSubmit: async () => {}, + }) + + return ( +
+ +
{ + e.preventDefault() + e.stopPropagation() + void form.handleSubmit() + }} + > +
+ {/* A type-safe field component*/} + {showField ? ( + + !value ? 'A first name is required' : undefined, + }} + children={(field) => { + // Avoid hasty abstractions. Render props are great! + return ( + <> + + field.handleChange(e.target.value)} + /> + + + ) + }} + /> + ) : null} +
+
+ ( + <> + + field.handleChange(e.target.value)} + /> + + + )} + /> +
+ [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + + +
+
+ ) + } + + const { getByText, findByText, queryByText } = render( + + + , + ) + + await user.click(getByText('Submit')) + expect(await findByText('A first name is required')).toBeInTheDocument() + await user.click(getByText('Hide field')) + await user.click(getByText('Submit')) + expect(queryByText('A first name is required')).not.toBeInTheDocument() + }) }) diff --git a/packages/react-form/src/tests/useForm.test.tsx b/packages/react-form/src/tests/useForm.test.tsx index 34d1d4f23..fc5b67629 100644 --- a/packages/react-form/src/tests/useForm.test.tsx +++ b/packages/react-form/src/tests/useForm.test.tsx @@ -172,6 +172,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange() { return error @@ -217,6 +221,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -262,6 +270,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChange: ({ value }) => value.firstName === 'other' ? error : undefined, @@ -362,6 +374,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsync: async () => { await sleep(10) @@ -412,6 +428,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsync: async () => { await sleep(10) @@ -472,6 +492,10 @@ describe('useForm', () => { function Comp() { const form = formFactory.useForm({ + defaultValues: { + firstName: '', + lastName: '', + }, validators: { onChangeAsyncDebounceMs: 100, onChangeAsync: async () => { diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 299d6c06b..afebf4a0d 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -1,9 +1,8 @@ -import React, { useRef, useState } from 'rehackt' +import React, { useState } from 'rehackt' import { useStore } from '@tanstack/react-store' import { FieldApi, functionalUpdate } from '@tanstack/form-core' import { formContext, useFormContext } from './formContext' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import { useIsomorphicEffectOnce } from './useIsomorphicEffectOnce' import type { UseFieldOptions } from './types' import type { DeepKeys, @@ -88,6 +87,8 @@ export function useField< return api }) + useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) + /** * fieldApi.update should not have any side effects. Think of it like a `useRef` * that we need to keep updated every render with the most up-to-date information. @@ -104,19 +105,6 @@ export function useField< } : undefined, ) - const unmountFn = useRef<(() => void) | null>(null) - - useIsomorphicEffectOnce(() => { - return () => { - unmountFn.current?.() - } - }) - - // We have to mount it right as soon as it renders, otherwise we get: - // https://github.com/TanStack/form/issues/523 - if (!unmountFn.current) { - unmountFn.current = fieldApi.mount() - } return fieldApi as never } diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index a79d24ca7..61745a1cc 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -46,7 +46,6 @@ export function useForm< opts?: FormOptions, ): FormApi { const [formApi] = useState(() => { - // @ts-ignore const api = new FormApi(opts) api.Provider = function Provider(props) { diff --git a/packages/react-form/src/useIsomorphicEffectOnce.ts b/packages/react-form/src/useIsomorphicEffectOnce.ts deleted file mode 100644 index ee0fc972a..000000000 --- a/packages/react-form/src/useIsomorphicEffectOnce.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useRef, useState } from 'rehackt' -import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import type { EffectCallback } from 'rehackt' - -/** - * This hook handles StrictMode and prod mode - */ -export const useIsomorphicEffectOnce = (effect: EffectCallback) => { - const destroyFunc = useRef void)>() - const effectCalled = useRef(false) - const renderAfterCalled = useRef(false) - const [val, setVal] = useState(0) - - if (effectCalled.current) { - renderAfterCalled.current = true - } - - useIsomorphicLayoutEffect(() => { - // only execute the effect first time around - if (!effectCalled.current) { - destroyFunc.current = effect() - effectCalled.current = true - } - - // this forces one render after the effect is run - setVal((v) => v + 1) - - return () => { - // if the comp didn't render since the useEffect was called, - // we know it's the dummy React cycle - if (!renderAfterCalled.current) { - return - } - if (destroyFunc.current) { - destroyFunc.current() - } - } - }, []) -}