Skip to content
4 changes: 2 additions & 2 deletions docs/framework/vue/guides/arrays.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const form = useForm({
</script>

<template>
<form.Field name="people">
<form.Field name="people" mode="array">
<template v-slot="{ field, state }">
<div>
<form.Field
Expand Down Expand Up @@ -100,7 +100,7 @@ const form = useForm({
"
>
<div>
<form.Field name="people">
<form.Field name="people" mode="array">
<template v-slot="{ field, state }">
<div>
<form.Field
Expand Down
2 changes: 1 addition & 1 deletion examples/vue/array/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const form = useForm({
"
>
<div>
<form.Field name="people">
<form.Field name="people" mode="array">
<template v-slot="{ field }">
<div>
<form.Field
Expand Down
111 changes: 107 additions & 4 deletions packages/vue-form/src/useField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FieldApi } from '@tanstack/form-core'
import { useStore } from '@tanstack/vue-store'
import { defineComponent, onMounted, onUnmounted, watch } from 'vue'
import { computed, defineComponent, onMounted, onUnmounted, watch } from 'vue'
import type {
AnyFieldApi,
AnyFieldMeta,
DeepKeys,
DeepValue,
FieldAsyncValidateOrFn,
Expand Down Expand Up @@ -253,7 +255,108 @@ export function useField<
return api
})()

const fieldState = useStore(fieldApi.store, (state) => state)
// For array mode, only track length changes to avoid re-renders when child properties change
// See: https://github.com/TanStack/form/issues/1925
const reactiveStateValue = useStore(
fieldApi.store,
(opts.mode === 'array'
? (state) => Object.keys((state.value as unknown) ?? []).length
: (state) => state.value) as (
state: typeof fieldApi.state,
) => TData | number,
)
const reactiveMetaIsTouched = useStore(
fieldApi.store,
(state) => state.meta.isTouched,
)
const reactiveMetaIsBlurred = useStore(
fieldApi.store,
(state) => state.meta.isBlurred,
)
const reactiveMetaIsDirty = useStore(
fieldApi.store,
(state) => state.meta.isDirty,
)
const reactiveMetaErrorMap = useStore(
fieldApi.store,
(state) => state.meta.errorMap,
)
const reactiveMetaErrorSourceMap = useStore(
fieldApi.store,
(state) => state.meta.errorSourceMap,
)
const reactiveMetaIsValidating = useStore(
fieldApi.store,
(state) => state.meta.isValidating,
)

const fieldState = computed(() => {
// For array mode, reactiveStateValue is the length (for reactivity tracking),
// so we need to read it to register the dependency, then return the actual
// value from fieldApi.
const trackedValue = reactiveStateValue.value
// Read all reactive meta refs eagerly so that fieldState recomputes (and
// dependent renders re-run) whenever any of them change. Without this, a
// consumer reading `field.getMeta()` or `field.state.meta` from a render
// function would not re-render on meta updates, since the meta getter
// would never have registered those dependencies.
const isTouched = reactiveMetaIsTouched.value
const isBlurred = reactiveMetaIsBlurred.value
const isDirty = reactiveMetaIsDirty.value
const errorMap = reactiveMetaErrorMap.value
const errorSourceMap = reactiveMetaErrorSourceMap.value
const isValidating = reactiveMetaIsValidating.value
return {
value:
opts.mode === 'array' ? fieldApi.state.value : (trackedValue as TData),
meta: {
...fieldApi.state.meta,
isTouched,
isBlurred,
isDirty,
errorMap,
errorSourceMap,
isValidating,
} satisfies AnyFieldMeta,
} satisfies AnyFieldApi['state']
})

const extendedFieldApi = computed(() => {
const reactiveFieldApi = {
...fieldApi,
get state() {
return fieldState.value
},
}

const extendedApi: FieldApi<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnDynamic,
TFormOnDynamicAsync,
TFormOnServer,
TParentSubmitMeta
> = reactiveFieldApi as never

return extendedApi
})

let cleanup!: () => void
onMounted(() => {
Expand All @@ -272,7 +375,7 @@ export function useField<
},
)

return { api: fieldApi, state: fieldState } as const
return { api: extendedFieldApi.value, state: fieldState.value } as const
}

export type FieldComponentProps<
Expand Down Expand Up @@ -435,7 +538,7 @@ export const Field = defineComponent(
return () =>
context.slots.default!({
field: fieldApi.api,
state: fieldApi.state.value,
state: fieldApi.state,
})
},
{ name: 'Field', inheritAttrs: false },
Expand Down
28 changes: 28 additions & 0 deletions packages/vue-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,32 @@ describe('useField', () => {
await user.click(await findByText('Submit'))
expect(fn).toHaveBeenCalledWith({ people: [{ name: 'John', age: 0 }] })
})

it('should support array mode', async () => {
const Comp = defineComponent(() => {
const form = useForm({
defaultValues: {
test: ['a'],
},
})

return () => (
<form.Field name="test" mode="array">
{({ field }: { field: AnyFieldApi }) => (
<div>
<div data-testid="val">{JSON.stringify(field.state.value)}</div>
<button onClick={() => field.pushValue('b')}>push</button>
</div>
)}
</form.Field>
)
})

const { getByTestId, getByText } = render(Comp)
expect(getByTestId('val')).toHaveTextContent('["a"]')
await user.click(getByText('push'))
await waitFor(() =>
expect(getByTestId('val')).toHaveTextContent('["a","b"]'),
)
})
})
Loading