Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/react/next-server-actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
"dependencies": {
"@tanstack/react-form-nextjs": "^1.27.1",
"@tanstack/react-store": "^0.8.0",
"next": "15.5.3",
"next": "16.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.1.0",
Expand Down
12 changes: 7 additions & 5 deletions examples/react/next-server-actions/src/app/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import {
ServerValidateError,
createServerValidate,
} from '@tanstack/react-form-nextjs'
import { z } from 'zod'
import { formOpts } from './shared-code'

const schema = z.object({
age: z.coerce.number().min(12),
firstName: z.string(),
})

const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
onServerValidate: schema,
})

export default async function someAction(prev: unknown, formData: FormData) {
Expand Down
23 changes: 9 additions & 14 deletions examples/react/next-server-actions/src/app/client-component.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,48 @@
'use client'

import { useActionState } from 'react'
import { useActionState, useCallback } from 'react'
import {
initialFormState,
mergeForm,
useForm,
useTransform,
} from '@tanstack/react-form-nextjs'
import { useStore } from '@tanstack/react-store'
import { z } from 'zod'
import someAction from './action'
import { formOpts } from './shared-code'

export const ClientComp = () => {
const [state, action] = useActionState(someAction, initialFormState)

// debugger

const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, state ?? {}),
transform: useCallback(
(baseForm: unknown) => mergeForm(baseForm as never, state ?? {}),
[state],
),
})

const formErrors = useStore(form.store, (formState) => formState.errors)

return (
<form action={action as never} onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as unknown as string}>{error}</p>
))}

<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
onChange: z.coerce.number().min(8),
}}
>
{(field) => {
return (
<div>
<input
name={field.name} // must explicitly set the name attribute for the POST request
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
<p key={error?.message ?? ''}>{error?.message}</p>
))}
</div>
)
Expand Down
10 changes: 8 additions & 2 deletions examples/react/next-server-actions/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
110 changes: 17 additions & 93 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,55 +217,6 @@ export interface FormValidators<
onDynamicAsyncDebounceMs?: number
}

/**
* @private
*/
export interface FormTransform<
TFormData,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
TSubmitMeta = never,
> {
fn: (
formBase: FormApi<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta
>,
) => FormApi<
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta
>
deps: unknown[]
}

export interface FormListeners<
TFormData,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
Expand Down Expand Up @@ -497,20 +448,6 @@ export interface FormOptions<
>
meta: TSubmitMeta
}) => void
transform?: FormTransform<
NoInfer<TFormData>,
NoInfer<TOnMount>,
NoInfer<TOnChange>,
NoInfer<TOnChangeAsync>,
NoInfer<TOnBlur>,
NoInfer<TOnBlurAsync>,
NoInfer<TOnSubmit>,
NoInfer<TOnSubmitAsync>,
NoInfer<TOnDynamic>,
NoInfer<TOnDynamicAsync>,
NoInfer<TOnServer>,
NoInfer<TSubmitMeta>
>
}

export type AnyFormOptions = FormOptions<
Expand Down Expand Up @@ -658,6 +595,20 @@ export type BaseFormState<
_force_re_eval?: boolean
}

export type AnyBaseFormState = BaseFormState<
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
>

export type DerivedFormState<
in out TFormData,
in out TOnMount extends undefined | FormValidateOrFn<TFormData>,
Expand Down Expand Up @@ -915,6 +866,7 @@ export class FormApi<
TOnServer,
TSubmitMeta
> = {}

baseStore!: Store<
BaseFormState<
TFormData,
Expand Down Expand Up @@ -969,11 +921,6 @@ export class FormApi<
return this.store.state
}

/**
* @private
*/
prevTransformArray: unknown[] = []

/**
* @private
*/
Expand Down Expand Up @@ -1259,7 +1206,7 @@ export class FormApi<
return prevVal
}

let state = {
const state = {
...currBaseStore,
errorMap,
fieldMeta: this.fieldMetaDerived.state,
Expand Down Expand Up @@ -1288,20 +1235,6 @@ export class FormApi<
TOnServer
>

// Only run transform if state has shallowly changed - IE how React.useEffect works
const transformArray = this.options.transform?.deps ?? []
const shouldTransform =
transformArray.length !== this.prevTransformArray.length ||
transformArray.some((val, i) => val !== this.prevTransformArray[i])

if (shouldTransform) {
const newObj = Object.assign({}, this, { state })
// This mutates the state
this.options.transform?.fn(newObj)
state = newObj.state
this.prevTransformArray = transformArray
}

return state
},
})
Expand Down Expand Up @@ -1439,11 +1372,6 @@ export class FormApi<
// Options need to be updated first so that when the store is updated, the state is correct for the derived state
this.options = options

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const shouldUpdateReeval = !!options.transform?.deps?.some(
(val, i) => val !== this.prevTransformArray[i],
)

const shouldUpdateValues =
options.defaultValues &&
!evaluate(options.defaultValues, oldOptions.defaultValues) &&
Expand All @@ -1453,7 +1381,7 @@ export class FormApi<
!evaluate(options.defaultState, oldOptions.defaultState) &&
!this.state.isTouched

if (!shouldUpdateValues && !shouldUpdateState && !shouldUpdateReeval) return
if (!shouldUpdateValues && !shouldUpdateState) return

batch(() => {
this.baseStore.setState(() =>
Expand All @@ -1469,10 +1397,6 @@ export class FormApi<
values: options.defaultValues,
}
: {},

shouldUpdateReeval
? { _force_re_eval: !this.state._force_re_eval }
: {},
),
),
)
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './standardSchemaValidator'
export * from './FieldGroupApi'
export * from './ValidationLogic'
export * from './EventClient'
export * from './transform'
67 changes: 42 additions & 25 deletions packages/form-core/src/mergeForm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { FormApi } from './FormApi'
import type {
FormApi,
FormAsyncValidateOrFn,
FormValidateOrFn,
} from './FormApi'

function isValidKey(key: string | number | symbol): boolean {
const dangerousProps = ['__proto__', 'constructor', 'prototype']
Expand Down Expand Up @@ -70,35 +74,48 @@ export function mutateMergeDeep(
return target
}

export function mergeForm<TFormData>(
export function mergeForm<
TFormData,
TOnMount extends undefined | FormValidateOrFn<TFormData>,
TOnChange extends undefined | FormValidateOrFn<TFormData>,
TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnBlur extends undefined | FormValidateOrFn<TFormData>,
TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnSubmit extends undefined | FormValidateOrFn<TFormData>,
TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnDynamic extends undefined | FormValidateOrFn<TFormData>,
TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>,
TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>,
TSubmitMeta = never,
>(
baseForm: FormApi<
NoInfer<TFormData>,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
TFormData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta
>,
state: Partial<
FormApi<
TFormData,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any,
any
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TOnServer,
TSubmitMeta
>['state']
>,
) {
Expand Down
Loading
Loading