Formik - 子组件意外重置整个表单状态

Formik - Child component unexpectedly resets entire form state

我有一个带有一些 textInputs 和一个自定义 CategoriesMultiselect 组件的表单,该组件弹出一个带有 SelectMultiple 的模式。当我 select 来自 multiselect 的一个选项时,它会调度一个 redux 动作,然后我的父表单被完全重置,除了我的类别。为什么要重置父表单?

具有以下形式的父组件:

//@flow

import * as Yup from 'yup'
import { withFormik } from 'formik'
import { Container } from 'native-base'
import * as React from 'react'
import { ScrollView, View } from 'react-native'
import { connect } from 'react-redux'
import { Category as CategoryEnums } from 'src/enums'
import type { VepoState } from 'src/components/model'
import type { RecordOf } from 'immutable'
import type { Product } from 'src/model'
import VepoHeader from 'src/components/formControls/header/view'
import { selectIsAddFormValid } from './selector'
import { selectProduct } from './selector'
import { Button } from 'src/components/formControls'
import { ImagePicker } from 'src/components/formControls'
import LocationAutocomplete from 'src/components/formControls/locationAutocomplete/view'
import { uploadAddProduct, updateRerenderKey } from './action'
import { viewStyle } from './style'
import type { Dispatch } from 'redux'
import { updateAddProductImage } from './action'
import type { Place } from 'src/model/location'
import { Colors, Spacing } from 'src/styles'
import { Input } from 'src/components/formControls'
import { onPress } from './controller'
import { CategoriesMultiselect } from 'src/components/formControls'
import { isLocationValid } from 'src/components/product/add/groceryItem/selector'

import { getSelectedSubcategories } from 'src/components/formControls/categoriesMultiselect/controller'

const mapStateToProps = (
  state: RecordOf<VepoState>,
  ownProps: { rerenderKey: boolean }
) => ({
  locationListDisplayed: state.formControls.root.locationListDisplayed,
  // $FlowFixMe
  categories: getSelectedSubcategories(state.formControls.categories),
  image: state.product.add.image,
  rerenderKey: ownProps.rerenderKey,
  location: state.formControls.location,
  isLocationValid: isLocationValid(state)
})

// eslint-disable-next-line flowtype/no-weak-types
const mapDispatchToProps = (dispatch: Dispatch<*>): Object => ({
  updateAddProductImage: (value): void => {
    dispatch(updateAddProductImage({ value }))
  },
  uploadAddProduct: (product: Product): void => {
    dispatch(uploadAddProduct(product))
  },
  updateRerenderKey: () => {
    dispatch(updateRerenderKey())
  }
})

export const getLocationIsValid = (place: Place): boolean => {
  return Object.keys(place).length > 0 ? true : false
}
type AddGroceryStoreState = {
  name: string,
  brand: string,
  description: string,
  price?: number
}

class AddGroceryItemView extends React.Component {
  render() {
    const {
      values,
      handleSubmit,
      setFieldValue,
      errors,
      touched,
      setFieldTouched,
      isValid,
      isSubmitting
    } = this.props

    console.log(errors)
    console.log(this.props)
    return (
      <Container>
        <VepoHeader title={'Add Vegan Grocery Product'} />
        <Container style={container}>
          <ScrollView
            keyboardShouldPersistTaps="always"
            style={viewStyle(this.props.locationListDisplayed).scrollView}>
            <View>
              <LocationAutocomplete
                label={'Grocery Store'}
                placeHolder={'Enter Grocery Store'}
              />
            </View>
            <View style={viewStyle().detailsContainer}>
              <ImagePicker
                label={'Product Image (optional)'}
                image={this.props.image.image}
                updateAddProductImage={this.props.updateAddProductImage}
                updateRerenderKey={this.props.updateRerenderKey}
              />
              <Input
                label={'Product Name'}
                onTouch={setFieldTouched}
                value={values.name}
                placeholder="Enter Name"
                name="name"
                required
                error={touched.name && errors.name}
                deleteText={setFieldValue}
                onChange={setFieldValue}
              />
              <Input
                label={'Product Brand'}
                value={values.brand}
                onTouch={setFieldTouched}
                error={touched.brand && errors.brand}
                placeholder="Enter Brand"
                name="brand"
                required
                onChange={setFieldValue}
                deleteText={setFieldValue}
              />
              <View>
                <Input
                  label={'Product Description'}
                  value={values.description}
                  placeholder="Enter Description"
                  multiline={true}
                  required
                  onTouch={setFieldTouched}
                  error={touched.description && errors.description}
                  numberOfLines={4}
                  name="description"
                  deleteText={setFieldValue}
                  onChange={setFieldValue}
                />
                <Input
                  isValid={true}
                  isPrice={true}
                  label={'Product Price'}
                  value={values.price}
                  onTouch={setFieldTouched}
                  error={touched.price && errors.price}
                  placeholder="Enter Price"
                  name="price"
                  deleteText={setFieldValue}
                  onChange={setFieldValue}
                />
                <View>
                  <CategoriesMultiselect.View
                    value={values.categories}
                    values={values}
                    error={errors.categories}
                    name="categories"
                    label="Product Categories"
                    categoryCodes={[CategoryEnums.CategoryCodes.Grocery]}
                  />
                </View>
              </View>
            </View>
          </ScrollView>
        </Container>
        <Button.View
          title="submit"
          onPress={handleSubmit}
          label={'GO!'}
          disabled={!isValid || isSubmitting}
          loading={isSubmitting}
        />
        {/* <Button.View onSub={this._handleSubmit} onPress={this._handleSubmit} label={'GO!'} /> */}
      </Container>
    )
  }
}

const container = {
  flex: 1,
  ...Spacing.horizontalPaddingLarge,
  backgroundColor: Colors.greyLight,
  flexDirection: 'column'
}

const formikEnhancer = withFormik({
  validationSchema: Yup.object().shape({
    name: Yup.string().required(),
    brand: Yup.string().required(),
    categories: Yup.array().required(),
    description: Yup.string()
      .min(9)
      .required(),
    price: Yup.number()
      .typeError('price must be a number')
      .required()
  }),
  enableReinitialize: true,
  mapPropsToValues: (props) => ({
    name: '',
    brand: '',
    description: '',
    price: '',
    categories: props.categories
  }),
  handleSubmit: (values, { props }) => {
    props.updateRerenderKey()
  },
  displayName: 'AddGroceryItemView'
})(AddGroceryItemView)

// $FlowFixMe
const AddGroceryItemViewComponent = connect(
  mapStateToProps,
  mapDispatchToProps
)(formikEnhancer)

export default AddGroceryItemViewComponent

重置表单的子组件:

//@flow
import type { Node } from 'react'
import { selectSelectedCategory } from 'src/components/product/add/groceryItem/selector'
import type { VepoState } from 'src/components/model'
import type { RecordOf } from 'immutable'
import { connect } from 'react-redux'
import * as React from 'react'
import { View } from 'react-native'
import {
  List,
  ListItem,
  Text,
  Left,
  Body,
  Right,
  Button,
  Container,
  Label,
  Title,
  Content
} from 'native-base'
import Icon from 'react-native-vector-icons/FontAwesome'
import Eicon from 'react-native-vector-icons/EvilIcons'
import Modal from 'react-native-modal'
import SelectMultiple from 'react-native-select-multiple'
import {
  updateAlertModalIsOpen,
  updateAlertModalHasYesNo,
  updateAlertModalMessage,
  updateAlertModalTitle
} from 'src/components/formControls/alertModal/action'
import * as C from './model'
import type { Subcategory } from 'src/model/category'

import * as controller from './controller'
import { getIsCategoriesValid } from './controller'
import { styles } from 'src/components/style'
import {
  Colors,
  Corners,
  Distances,
  Modals,
  Spacing,
  Typography,
  ZIndexes
} from 'src/styles'
import { Containers } from '../../../styles'
import {
  toggleSubcategory,
  setAllShowSubcategoriesToFalse,
  toggleShowSubcategories
} from './action'
import type { Dispatch } from 'redux'

const mapStateToProps = (state: RecordOf<VepoState>) => ({
  vepo: state,
  // $FlowFixMe
  selectedCategory: selectSelectedCategory(state),
  categories: state.formControls.categories
})

// eslint-disable-next-line flowtype/no-weak-types
const mapDispatchToProps = (dispatch: Dispatch<*>): Object => ({
  setAllShowSubcategoriesToFalse: (): void => {
    dispatch(setAllShowSubcategoriesToFalse())
  },
  toggleSubcategory: (sc): void => {
    dispatch(toggleSubcategory(sc))
  },
  toggleShowSubcategories: (c): void => {
    dispatch(toggleShowSubcategories(c))
  },
  updateAlertModalIsOpen: (isOpen: boolean): void => {
    dispatch(updateAlertModalIsOpen(isOpen))
  },
  updateAlertModalMessage: (message: string): void => {
    dispatch(updateAlertModalMessage(message))
  },
  updateAlertModalHasYesNo: (hasYesNo: boolean): void => {
    dispatch(updateAlertModalHasYesNo(hasYesNo))
  },
  updateAlertModalTitle: (title: string): void => {
    dispatch(updateAlertModalTitle(title))
  }
})
const handleClick = (props, item) => {
  controller.categoryClicked(props, item)
}
const renderCategoryRow = (props: C.CategoriesViewProps, item: C.Category) => {
  return (
    <View>
      <ListItem style={listItem} icon onPress={() => handleClick(props, item)}>
        <Left>
          <Icon
            style={styles.icon}
            name={item.icon}
            size={20}
            color={item.iconColor}
          />
        </Left>
        <Body style={[styles.formElementHeight, border(item)]}>
          <Text style={Typography.brownLabel}>{item.label}</Text>
        </Body>
        <Right style={[styles.formElementHeight, border(item)]}>
          <Eicon style={catStyle.arrow} name="chevron-right" size={30} />
        </Right>
      </ListItem>
    </View>
  )
}
const getCategoriesToDisplay = (props) => {
  const y = props.categories.filter((x) => props.categoryCodes.includes(x.code))
  return y
}

let CategoriesMultiselect: React.ComponentType<C.CategoriesViewProps> = (
  props: C.CategoriesViewProps
): Node => {
  const categoriesToDisplay = getCategoriesToDisplay(props)
  return (
    <View>
      <View style={{ ...Containers.fullWidthRow }}>
        <Label disabled={false} style={Typography.formLabel}>
          {props.label}
        </Label>
        <View style={{ ...Containers.fullWidthRow }} />
        <Label disabled={false} style={Typography.formLabel}>
          {controller.getNumberOfSelectedSubcategories(props.categories)}{' '}
          Selected
        </Label>
      </View>
      <View style={catStyle.categoriesViewStyle(props, categoriesToDisplay)}>
        {props.categories && props.categories.length > 0 && (
          <List
            listBorderColor={'white'}
            style={categoriesListStyle}
            dataArray={categoriesToDisplay}
            renderRow={(item: C.Category) => {
              return renderCategoryRow(props, item)
            }}
          />
        )}
        <View style={catStyle.modalConatinerStyle} />
        <Modal
          style={catStyle.modal}
          isVisible={
            props.categories
              ? props.categories.some((cat: C.Category) =>
                  controller.showModal(cat)
                )
              : false
          }>
          <Container style={catStyle.modalView}>
            <View style={Modals.modalHeader}>
              <Title style={catStyle.categoriesTitleStyle}>
                {controller.getDisplayedCategoryLabel(props.categories)}
              </Title>
              <Right>
                <Button
                  transparent
                  icon
                  onPress={props.setAllShowSubcategoriesToFalse}>
                  <Eicon name="close-o" size={25} color="#FFFFFF" />
                </Button>
              </Right>
            </View>
            <Content style={catStyle.categoryStyle.modalContent}>
              <SelectMultiple
                checkboxSource={require('../../../images/unchecked.png')}
                selectedCheckboxSource={require('../../../images/checked.png')}
                labelStyle={[
                  styles.label,
                  styles.formElementHeight,
                  styles.modalListItem
                ]}
                items={controller.getDisplayedSubcategories(props.categories)}
                selectedItems={controller.getSelectedSubcategories(
                  props.categories
                )}
                onSelectionsChange={(selections, item: Subcategory) =>
                  props.toggleSubcategory({ subcategory: item })
                }
              />
            </Content>
          </Container>
        </Modal>
      </View>
      {props.error && (
        <Label
          disabled={false}
          style={[
            Typography.formLabel,
            { color: 'red' },
            { marginBottom: Spacing.medium }
          ]}>
          {props.error}
        </Label>
      )}
    </View>
  )
}

const catStyle = {
  // eslint-disable-next-line no-undef
  getBorderBottomWidth: (item: C.Category): number => {
    if (item.icon === 'shopping-basket') {
      return Spacing.none
    }
    return Spacing.none
  },
  // eslint-disable-next-line no-undef
  categoriesViewStyle: (props: C.CategoriesViewProps, categoriesToDisplay) => {
    return {
      backgroundColor: Colors.borderLeftColor(
        getIsCategoriesValid(props.categories)
      ),
      ...Corners.rounded,
      paddingLeft: Spacing.medium,
      height: Distances.FormElementHeights.Medium * categoriesToDisplay.length,
      overflow: 'hidden',
      borderBottomWidth: Spacing.none
    }
  },
  arrow: {
    color: Colors.brownDark,
    borderBottomColor: Colors.brownDark
  },
  icon: { height: Distances.FormElementHeights.Medium },
  // eslint-disable-next-line no-undef
  categoriesTitleStyle: {
    ...styles.title,
    ...Typography.titleLeftAlign
  },
  categoryStyle: {
    modalContent: {
      ...Corners.rounded
    }
  },
  modal: {
    flex: 0.7,
    height: 20,
    marginTop: Spacing.auto,
    marginBottom: Spacing.auto
  },
  modalView: {
    backgroundColor: Colors.white,
    height: 500,
    ...Corners.rounded
  },
  modalConatinerStyle: {
    marginBottom: Spacing.medium,
    color: Colors.brownDark,
    backgroundColor: Colors.brownLight,
    position: 'absolute',
    zIndex: ZIndexes.Layers.Negative,
    right: Spacing.none,
    height: Distances.Distances.Full,
    width: Distances.Distances.Full,
    ...Corners.rounded
  }
}

const categoriesListStyle = {
  flex: Distances.FlexDistances.Full,
  color: Colors.brownDark,
  backgroundColor: Colors.brownLight,
  height: Distances.FormElementHeights.Double,
  ...Corners.notRounded,
  marginRight: Spacing.medium
}

const border = (item: C.Category) => {
  return {
    borderBottomWidth: catStyle.getBorderBottomWidth(item),
    borderBottomColor: Colors.brownMedium
  }
}

const listItem = {
  height: Distances.FormElementHeights.Medium
}

// $FlowFixMe
CategoriesMultiselect = connect(
  mapStateToProps,
  mapDispatchToProps
)(CategoriesMultiselect)

export default CategoriesMultiselect

这部分似乎重置了父表单:

        onSelectionsChange={(selections, item: Subcategory) =>
          props.toggleSubcategory({ subcategory: item })
        }

这是因为 categories 发生了变化,因为它是 Redux 存储的一部分,它会触发 Formik 的 mapPropsToValues,除了 categories 之外的所有内容的初始值为空字符串。解决方法是停止在 Formik 的 mapPropsToValues 类别中使用 redux 存储,它变成:

  mapPropsToValues: () => ({
    name: '',
    brand: '',
    description: '',
    price: '',
    categories: []
  }),

然后更新 categories 的 Formik 表单值,将 Formik 的 setFieldValue 传递给子组件并使用它。

父级:

          <CategoriesMultiselect.View
            error={errors.categories}
            setFieldValue={setFieldValue}
            name="categories"
            label="Product Categories"
            categoryCodes={[CategoryEnums.CategoryCodes.Grocery]}
          />

CategoriesMultiselect.View中:

class CategoriesMultiselectView extends React.Component {
  setFormCategories = () => {
    if (this.props && this.props.setFieldValue) {
      this.props.setFieldValue(
        'categories',
        controller.getSelectedSubcategories(this.props.categories)
      )
    }
  }

      render(): React.Node {
        return (
          <View>
              <Modal
                style={catStyle.modal}
                onModalHide={this.setFormCategories}
                isVisible={
                ...
         </View>

我的回答是问题标题,而不是 OP 发布的具体问题。


我刚遇到类似的事情;更改 child 组件中的单个 Formik 值会意外地导致更改 Formik 的不相关部分。在我的例子中,特别是 touched 字段将重置为未被触摸,即使它们之前都被触摸过。

原因是 initialValues 被传递给 useFormik 的误用。 initialValues 在 parent 中动态生成,用于设置每个 child 的起始值。没问题。问题是,当 child 值被更改时,会启动一个回调,该回调会返回并更改 initialValues 以匹配 children 中的新 values,这然后传回 useFormik,新的起始值传回给 children。因为新的 initialValues 掩盖了一般的 values 本来的样子,所以很难看出有什么问题。但是由于新的 initialValues 进来,它导致 Formik 完全重置 touched 字段,因为(我相信,我实际上并没有深入挖掘 Formik 的源代码)它实际上是在制作一个全新的 Formik object,不是上一个的延续。

TL;DR: 如果您看到您的 Formik 中发生了奇怪的重置,请查看您传入的 initialValues。如果他们曾经更改,这很可能是重置的原因。