diff --git a/docs/demo/useWatch-list.md b/docs/demo/useWatch-list.md
new file mode 100644
index 00000000..1a14f737
--- /dev/null
+++ b/docs/demo/useWatch-list.md
@@ -0,0 +1,3 @@
+## useWatch-list
+
+
diff --git a/docs/demo/useWatch.md b/docs/demo/useWatch.md
new file mode 100644
index 00000000..04e70536
--- /dev/null
+++ b/docs/demo/useWatch.md
@@ -0,0 +1,3 @@
+## useWatch
+
+
diff --git a/docs/examples/useWatch-list.tsx b/docs/examples/useWatch-list.tsx
new file mode 100644
index 00000000..d6677ce0
--- /dev/null
+++ b/docs/examples/useWatch-list.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import Form, { Field } from 'rc-field-form';
+import Input from './components/Input';
+
+const { List, useForm } = Form;
+
+const Demo = () => {
+ const [form] = useForm();
+ const users = Form.useWatch(['users'], form) || [];
+
+ console.log('values', users);
+
+ return (
+
+ );
+};
+
+export default Demo;
diff --git a/docs/examples/useWatch.tsx b/docs/examples/useWatch.tsx
new file mode 100644
index 00000000..1f0056e9
--- /dev/null
+++ b/docs/examples/useWatch.tsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react';
+import Form, { Field } from 'rc-field-form';
+import Input from './components/Input';
+
+let x = 0;
+
+const Demo = React.memo(() => {
+ const values = Form.useWatch(['demo']);
+ console.log('demo watch', values);
+ return (
+
+
+
+ );
+});
+const Demo2 = React.memo(() => {
+ const values = Form.useWatch(['demo2']);
+ console.log('demo2 watch', values);
+ return (
+
+
+
+ );
+});
+
+export default () => {
+ const [form] = Form.useForm();
+ const [visible, setVisible] = useState(true);
+ const [visible2, setVisible2] = useState(true);
+ const [visible3, setVisible3] = useState(true);
+ const values = Form.useWatch([], form);
+ console.log('main watch', values);
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/FieldContext.ts b/src/FieldContext.ts
index 2cacf058..2940e3c4 100644
--- a/src/FieldContext.ts
+++ b/src/FieldContext.ts
@@ -36,6 +36,7 @@ const Context = React.createContext({
setInitialValues: warningFunc,
destroyForm: warningFunc,
setCallbacks: warningFunc,
+ registerWatch: warningFunc,
getFields: warningFunc,
setValidateMessages: warningFunc,
setPreserve: warningFunc,
diff --git a/src/index.tsx b/src/index.tsx
index 031e68e8..3e24431c 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -7,6 +7,7 @@ import FieldForm, { FormProps } from './Form';
import { FormProvider } from './FormContext';
import FieldContext from './FieldContext';
import ListContext from './ListContext';
+import useWatch from './useWatch';
const InternalForm = React.forwardRef(FieldForm) as (
props: FormProps & { ref?: React.Ref> },
@@ -18,6 +19,7 @@ interface RefFormType extends InternalFormType {
Field: typeof Field;
List: typeof List;
useForm: typeof useForm;
+ useWatch: typeof useWatch;
}
const RefForm: RefFormType = InternalForm as RefFormType;
@@ -26,6 +28,7 @@ RefForm.FormProvider = FormProvider;
RefForm.Field = Field;
RefForm.List = List;
RefForm.useForm = useForm;
+RefForm.useWatch = useWatch;
export { FormInstance, Field, List, useForm, FormProvider, FormProps, FieldContext, ListContext };
diff --git a/src/interface.ts b/src/interface.ts
index d0cb11a3..362eff54 100644
--- a/src/interface.ts
+++ b/src/interface.ts
@@ -193,6 +193,8 @@ export interface Callbacks {
onFinishFailed?: (errorInfo: ValidateErrorEntity) => void;
}
+export type WatchCallBack = (values: Store, namePathList: InternalNamePath[]) => void;
+
export interface InternalHooks {
dispatch: (action: ReducerAction) => void;
initEntityValue: (entity: FieldEntity) => void;
@@ -201,6 +203,7 @@ export interface InternalHooks {
setInitialValues: (values: Store, init: boolean) => void;
destroyForm: () => void;
setCallbacks: (callbacks: Callbacks) => void;
+ registerWatch: (callback: WatchCallBack) => () => void;
getFields: (namePathList?: InternalNamePath[]) => FieldData[];
setValidateMessages: (validateMessages: ValidateMessages) => void;
setPreserve: (preserve?: boolean) => void;
@@ -255,6 +258,9 @@ export type InternalFormInstance = Omit & {
* We pass the `HOOK_MARK` as key to avoid user call the function.
*/
getInternalHooks: (secret: string) => InternalHooks | null;
+
+ /** @private Internal usage. Do not use it in your production */
+ _init?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/src/useForm.ts b/src/useForm.ts
index 025a62b8..45e3e89c 100644
--- a/src/useForm.ts
+++ b/src/useForm.ts
@@ -21,6 +21,7 @@ import type {
InternalFieldData,
ValuedNotifyInfo,
RuleError,
+ WatchCallBack,
} from './interface';
import { HOOK_MARK } from './FieldContext';
import { allPromiseFinish } from './utils/asyncUtil';
@@ -93,6 +94,7 @@ export class FormStore {
setFieldsValue: this.setFieldsValue,
validateFields: this.validateFields,
submit: this.submit,
+ _init: true,
getInternalHooks: this.getInternalHooks,
});
@@ -114,6 +116,7 @@ export class FormStore {
getFields: this.getFields,
setPreserve: this.setPreserve,
getInitialValue: this.getInitialValue,
+ registerWatch: this.registerWatch,
};
}
@@ -141,6 +144,7 @@ export class FormStore {
// We will take consider prev form unmount fields.
// When the field is not `preserve`, we need fill this with initialValues instead of store.
+ // eslint-disable-next-line array-callback-return
this.prevWithoutPreserves?.map(({ key: namePath }) => {
nextStore = setValue(nextStore, namePath, getValue(initialValues, namePath));
});
@@ -180,6 +184,28 @@ export class FormStore {
this.preserve = preserve;
};
+ // ============================= Watch ============================
+ private watchList: WatchCallBack[] = [];
+
+ private registerWatch: InternalHooks['registerWatch'] = callback => {
+ this.watchList.push(callback);
+
+ return () => {
+ this.watchList = this.watchList.filter(fn => fn !== callback);
+ };
+ };
+
+ private notifyWatch = (namePath: InternalNamePath[] = []) => {
+ // No need to cost perf when nothing need to watch
+ if (this.watchList.length) {
+ const values = this.getFieldsValue();
+
+ this.watchList.forEach(callback => {
+ callback(values, namePath);
+ });
+ }
+ };
+
// ========================== Dev Warning =========================
private timeoutId: any = null;
@@ -498,6 +524,7 @@ export class FormStore {
this.updateStore(setValues({}, this.initialValues));
this.resetWithFieldInitialValue();
this.notifyObservers(prevStore, null, { type: 'reset' });
+ this.notifyWatch();
return;
}
@@ -509,6 +536,7 @@ export class FormStore {
});
this.resetWithFieldInitialValue({ namePathList });
this.notifyObservers(prevStore, namePathList, { type: 'reset' });
+ this.notifyWatch(namePathList);
};
private setFields = (fields: FieldData[]) => {
@@ -516,9 +544,12 @@ export class FormStore {
const prevStore = this.store;
+ const namePathList: InternalNamePath[] = [];
+
fields.forEach((fieldData: FieldData) => {
const { name, errors, ...data } = fieldData;
const namePath = getNamePath(name);
+ namePathList.push(namePath);
// Value
if ('value' in data) {
@@ -530,6 +561,8 @@ export class FormStore {
data: fieldData,
});
});
+
+ this.notifyWatch(namePathList);
};
private getFields = (): InternalFieldData[] => {
@@ -573,6 +606,8 @@ export class FormStore {
private registerField = (entity: FieldEntity) => {
this.fieldEntities.push(entity);
+ const namePath = entity.getNamePath();
+ this.notifyWatch([namePath]);
// Set initial values
if (entity.props.initialValue !== undefined) {
@@ -591,8 +626,6 @@ export class FormStore {
const mergedPreserve = preserve !== undefined ? preserve : this.preserve;
if (mergedPreserve === false && (!isListField || subNamePath.length > 1)) {
- const namePath = entity.getNamePath();
-
const defaultValue = isListField ? undefined : this.getInitialValue(namePath);
if (
@@ -614,6 +647,8 @@ export class FormStore {
this.triggerDependenciesUpdate(prevStore, namePath);
}
}
+
+ this.notifyWatch([namePath]);
};
};
@@ -679,6 +714,7 @@ export class FormStore {
type: 'valueUpdate',
source: 'internal',
});
+ this.notifyWatch([namePath]);
// Dependencies update
const childrenFields = this.triggerDependenciesUpdate(prevStore, namePath);
@@ -701,13 +737,15 @@ export class FormStore {
const prevStore = this.store;
if (store) {
- this.updateStore(setValues(this.store, store));
+ const nextStore = setValues(this.store, store);
+ this.updateStore(nextStore);
}
this.notifyObservers(prevStore, null, {
type: 'valueUpdate',
source: 'external',
});
+ this.notifyWatch();
};
private getDependencyChildrenFields = (rootNamePath: InternalNamePath): InternalNamePath[] => {
diff --git a/src/useWatch.ts b/src/useWatch.ts
new file mode 100644
index 00000000..2d39c39e
--- /dev/null
+++ b/src/useWatch.ts
@@ -0,0 +1,58 @@
+import type { FormInstance } from '.';
+import { FieldContext } from '.';
+import warning from 'rc-util/lib/warning';
+import { HOOK_MARK } from './FieldContext';
+import type { InternalFormInstance, NamePath, Store } from './interface';
+import { useState, useContext, useEffect, useRef } from 'react';
+import { getNamePath, getValue } from './utils/valueUtil';
+
+const useWatch = (dependencies: NamePath = [], form?: FormInstance) => {
+ const [value, setValue] = useState();
+
+ const fieldContext = useContext(FieldContext);
+ const formInstance = (form as InternalFormInstance) || fieldContext;
+ const isValidForm = formInstance && formInstance._init;
+
+ // Warning if not exist form instance
+ if (process.env.NODE_ENV !== 'production') {
+ warning(
+ isValidForm,
+ 'useWatch requires a form instance since it can not auto detect from context.',
+ );
+ }
+
+ const namePath = getNamePath(dependencies);
+ const namePathRef = useRef(namePath);
+ namePathRef.current = namePath;
+
+ useEffect(
+ () => {
+ // Skip if not exist form instance
+ if (!isValidForm) {
+ return;
+ }
+
+ const { getFieldsValue, getInternalHooks } = formInstance;
+ const { registerWatch } = getInternalHooks(HOOK_MARK);
+
+ const cancelRegister = registerWatch(store => {
+ const newValue = getValue(store, namePathRef.current);
+ setValue(newValue);
+ });
+
+ // TODO: We can improve this perf in future
+ const initialValue = getValue(getFieldsValue(), namePathRef.current);
+ setValue(initialValue);
+
+ return cancelRegister;
+ },
+ /* eslint-disable react-hooks/exhaustive-deps */
+ // We do not need re-register since namePath content is the same
+ [],
+ /* eslint-enable */
+ );
+
+ return value;
+};
+
+export default useWatch;
diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx
new file mode 100644
index 00000000..7a1809eb
--- /dev/null
+++ b/tests/useWatch.test.tsx
@@ -0,0 +1,222 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import type { FormInstance } from '../src';
+import { List } from '../src';
+import Form, { Field } from '../src';
+import timeout from './common/timeout';
+import { act } from 'react-dom/test-utils';
+import { Input } from './common/InfoField';
+
+describe('useWatch', () => {
+ let staticForm: FormInstance;
+
+ it('field initialValue', async () => {
+ const Demo = () => {
+ const [form] = Form.useForm();
+ const nameValue = Form.useWatch('name', form);
+
+ return (
+
+ );
+ };
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+ });
+ });
+
+ it('form initialValue', async () => {
+ const Demo = () => {
+ const [form] = Form.useForm();
+ const nameValue = Form.useWatch(['name'], form);
+
+ return (
+
+ );
+ };
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+ });
+ });
+
+ it('change value with form api', async () => {
+ const Demo = () => {
+ const [form] = Form.useForm();
+ const nameValue = Form.useWatch(['name'], form);
+
+ return (
+
+ );
+ };
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+ staticForm.setFields([{ name: 'name', value: 'little' }]);
+ expect(wrapper.find('.values').text()).toEqual('little');
+
+ staticForm.setFieldsValue({ name: 'light' });
+ expect(wrapper.find('.values').text()).toEqual('light');
+
+ staticForm.resetFields();
+ expect(wrapper.find('.values').text()).toEqual('');
+ });
+ });
+
+ describe('unmount', () => {
+ it('basic', async () => {
+ const Demo = ({ visible }: { visible: boolean }) => {
+ const [form] = Form.useForm();
+ const nameValue = Form.useWatch(['name'], form);
+
+ return (
+
+ );
+ };
+
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+
+ wrapper.setProps({ visible: false });
+ expect(wrapper.find('.values').text()).toEqual('');
+
+ wrapper.setProps({ visible: true });
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+ });
+ });
+
+ it('nest children component', async () => {
+ const DemoWatch = () => {
+ Form.useWatch(['name']);
+
+ return (
+
+
+
+ );
+ };
+
+ const Demo = ({ visible }: { visible: boolean }) => {
+ const [form] = Form.useForm();
+ const nameValue = Form.useWatch(['name'], form);
+
+ return (
+
+ );
+ };
+
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+
+ wrapper.setProps({ visible: false });
+ expect(wrapper.find('.values').text()).toEqual('');
+
+ wrapper.setProps({ visible: true });
+ expect(wrapper.find('.values').text()).toEqual('bamboo');
+ });
+ });
+ });
+
+ it('list', async () => {
+ const Demo = () => {
+ const [form] = Form.useForm();
+ const users = Form.useWatch(['users'], form) || [];
+
+ return (
+
+ );
+ };
+ await act(async () => {
+ const wrapper = mount();
+ await timeout();
+ expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['bamboo', 'light']));
+
+ wrapper.find('.remove').at(0).simulate('click');
+ await timeout();
+ expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['light']));
+ });
+ });
+
+ it('warning if not provide form', () => {
+ const errorSpy = jest.spyOn(console, 'error');
+
+ const Demo = () => {
+ Form.useWatch([]);
+ return null;
+ };
+
+ mount();
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ 'Warning: useWatch requires a form instance since it can not auto detect from context.',
+ );
+ });
+});