useFormik 导致太多重新渲染

useFormik cause too many re-renders

堆栈:

我有一个用户页面,显示用户列表。此页面也有删除用户和编辑用户操作。编辑用户,将所选用户保存在 Redux 状态,并打开一个带有新组件 UserDetail 的 MUI5 SwipeableDrawer。在 UserDetail 页面中,使用 useState 获取 selectedUser 并将其设置为使用 Formik 增强的表单。但它因“重新渲染太多”错误而失败。

相关文件如下:

用户组件:

import React, {useState} from 'react';
import {
  Alert,
  Box,
  Button,
  ButtonGroup,
  CircularProgress,
  Container,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  Snackbar,
  SwipeableDrawer,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableFooter,
  TableHead,
  TableRow
} from "@mui/material";
import moment from "moment";
import {Delete, Edit, PersonAdd} from "@mui/icons-material";
import {useAppDispatch} from 'services/hooks';

import {useRouter} from "next/router";
import {NextPage} from "next";
import {useDeleteUserMutation, useGetUsersQuery} from "../../services/UserService";
import Footer from "../../components/Footer/Footer";
import UserDetail from "./components/UserDetail";
import {UserType} from "../../services/types/UserType";
import {setUser} from "../../services/slices/UserSlice";

const EMPTY_DIALOG = {
  open: false,
  text: '',
  title: '',
  onConfirm: () => {
  },
  onCancel: () => {
  }
}

const EMPTY_ALERT = {
  open: false,
  text: '',
};

const Users: NextPage = () => {

  console.log('users')

  const router = useRouter();
  const dispatch = useAppDispatch();
  const [offset, setOffset] = useState(0);
  const [limit, setLimit] = useState(10);
  const [dialog, setDialog] = useState(EMPTY_DIALOG);
  const [alert, setAlert] = useState(EMPTY_ALERT);

  const {
    data,
    error,
    isLoading: isUsersLoading,
    isSuccess: isUsersQueried,
    isFetching: isUsersFetching,
    isError: isUsersError
  } = useGetUsersQuery();

  const [deleteUser, {
    data: deletedUser,
    isLoading: isUserDeleting,
    isSuccess: isUserDeleted
  }] = useDeleteUserMutation();

  const drawerBleeding = 56;
  const [openDrawer, setOpenDrawer] = React.useState(false);

  const handleDeleteUser = (userId: number) => async () => {
    try {
      await deleteUser(userId).unwrap();
      setAlert({
        open: true,
        text: `Successfully deleted user: ${userId}`,
      });
      resetDeleteDialog();

    } catch (error) {
      console.log(`Error: Failed deleting user with id ${userId}`);
    }
  };

  const resetDeleteDialog = () => {
    setDialog(EMPTY_DIALOG);
  }

  const openDeleteDialog = (userId: number) => () => {
    setDialog({
      open: true,
      title: 'Delete user',
      text: `Delete user: ${userId}?`,
      onConfirm: handleDeleteUser(userId),
      onCancel: () => resetDeleteDialog()
    });
  }

  const resetAlert = () => {
    setAlert(EMPTY_ALERT);
  }

  const editUser = (user: UserType) => () => {

    setOpenDrawer(true);
    dispatch(setUser(user));
  };

  const toggleEditDrawer = (newOpen: boolean) => () => {
    setOpenDrawer(newOpen);
  };

  const renderTable = (users: UserType[], count: number) => {
    const hasUsers = count > 0;

    return (
        <React.Fragment>
          <TableContainer>
            <Table>
              <TableHead>
                <TableRow>
                  <TableCell colSpan={6} align="right">
                    <Button variant="outlined" color="primary" onClick={toggleEditDrawer(true)}>
                      <PersonAdd/>
                    </Button>
                  </TableCell>
                </TableRow>
                <TableRow>
                  <TableCell>Id</TableCell>
                  <TableCell>First name</TableCell>
                  <TableCell>Last name</TableCell>
                  <TableCell>Email</TableCell>
                  <TableCell>Birth date</TableCell>
                  <TableCell></TableCell>
                </TableRow>
              </TableHead>
              <TableBody>
                {hasUsers ? (
                    users.map((user) => (
                        <TableRow key={user.id}>
                          <TableCell>{user.id}</TableCell>
                          <TableCell>{user.firstName}</TableCell>
                          <TableCell>{user.lastName}</TableCell>
                          <TableCell>{user.email}</TableCell>
                          <TableCell>
                            {moment.utc(user.birthDate).format('MM-DD-YYYY')}
                          </TableCell>
                          <TableCell sx={{textAlign: "right"}}>
                            <ButtonGroup>
                              <Button onClick={editUser(user)}>
                                <Edit/>
                              </Button>
                              <Button onClick={openDeleteDialog(user.id)}>
                                {<Delete/>}
                              </Button>
                            </ButtonGroup>
                          </TableCell>
                        </TableRow>
                    ))
                ) : (
                    <TableRow>
                      <TableCell colSpan={6}>No users found.</TableCell>
                    </TableRow>
                )}
              </TableBody>
              <TableFooter>
                <TableRow>
                  {/*<TablePagination*/}
                  {/*    component={TableCell}*/}
                  {/*    count={count}*/}
                  {/*    page={offset}*/}
                  {/*    rowsPerPage={limit}*/}
                  {/*    onChangePage={handleChangePage}*/}
                  {/*    onChangeRowsPerPage={handleChangeRowsPerPage}*/}
                  {/*/>*/}
                </TableRow>
              </TableFooter>
            </Table>
          </TableContainer>
          <SwipeableDrawer
              anchor="bottom"
              open={openDrawer}
              onClose={toggleEditDrawer(false)}
              onOpen={toggleEditDrawer(true)}
              swipeAreaWidth={drawerBleeding}
              disableSwipeToOpen={false}
              ModalProps={{
                keepMounted: true,
              }}
          >
            <UserDetail toggleEditDrawer={toggleEditDrawer}></UserDetail>
          </SwipeableDrawer>
        </React.Fragment>
    );
  }

  const renderBody = () => {
    if (isUsersQueried) {
      const {users, count} = data;

      return (isUsersFetching || isUsersLoading) ?
          <Box sx={{display: 'flex'}}>
            <CircularProgress/>
          </Box> :
          renderTable(users, count)
    }
  }

  const renderError = () => {
    return isUsersError && <Alert severity="error">{error}</Alert>;
  }

  return (
      <Container maxWidth={"md"} fixed>
        {renderError()}
        {renderBody()}
        <Footer></Footer>
        <Dialog
            open={dialog.open}
            onClose={dialog.onCancel}
            aria-labelledby="alert-dialog-title"
            aria-describedby="alert-dialog-description"
        >
          <DialogTitle id="alert-dialog-title">
            {dialog.title}
          </DialogTitle>
          <DialogContent>
            <DialogContentText id="alert-dialog-description">
              {dialog.text}
            </DialogContentText>
          </DialogContent>
          <DialogActions>
            <Button onClick={dialog.onCancel}>Disagree</Button>
            <Button onClick={dialog.onConfirm} autoFocus>
              Agree
            </Button>
          </DialogActions>
        </Dialog>
        <Snackbar
            open={alert.open}
            autoHideDuration={6000}
            onClose={resetAlert}
            message={alert.text}
        />
      </Container>
  );
}

export default Users;

UserDetail 组件:

import React, {useState} from 'react';
import {Alert, Box, Button, Container, Grid, TextField, Typography} from "@mui/material";
import {useSelector} from "react-redux";
import * as yup from 'yup';
import {useFormik} from "formik";
import AdapterMoment from '@mui/lab/AdapterMoment';
import LocalizationProvider from '@mui/lab/LocalizationProvider';
import {DatePicker} from "@mui/lab";
import {NextPage} from "next";
import Footer from "../../../components/Footer/Footer";
import {useCreateUserMutation, useUpdateUserMutation} from "../../../services/UserService";
import {UserType} from "../../../services/types/UserType";
import {AppProps} from "next/app";
import {selectUser} from "../../../services/slices/UserSlice";

const validationSchema = yup.object({
  email: yup
  .string()
  .trim()
  .email('Please enter a valid email address')
  .required('Email is required.'),
  firstName: yup
  .string()
  .required('Please specify your first name'),
  lastName: yup
  .string()
  .required('Please specify your first name'),
  birthDate: yup
  .date()
});

const INITIAL_USER = {
  firstName: '',
  lastName: '',
  email: ''
}

const UserDetail: NextPage = ({toggleEditDrawer}: AppProps) => {

  console.log('user detail')

  const [birthDate, setBirthDate] = useState(null);
  const [pageError, setPageError] = useState(null);
  const user = useSelector(selectUser);

  const [createUser, {
    isLoading: isUserCreating,
    isSuccess: isUserCreated
  }] = useCreateUserMutation();

  // you can get the detailed user if really needed
  // const {
  //   data: user,
  //   isLoading: isUserLoading
  // } = useGetUserQuery(user.id);

  const [updateUser, {isLoading: isUserUpdating}] = useUpdateUserMutation();

  const onSubmit = (values: UserType) => {

    let newValues = {
      ...values,
      birthDate: birthDate.toISOString()
    }

    try {
      if (user && user.id) {
        newValues.id = user.id;
        updateUser(newValues).unwrap();

      } else {
        createUser(newValues).unwrap();
      }

    } catch (error) {
      setPageError(error);

    } finally {
      toggleEditDrawer(false)();
    }
  }

  const formik = useFormik({
    initialValues: INITIAL_USER,
    validationSchema: validationSchema,
    onSubmit
  });

  const renderForm = () => {

    console.log(user.birthDate)
    setBirthDate(moment(user.birthDate));
    
    // this part of the code causes the too many re-render error
    formik.setValues({
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email
    });

    return (
        <form onSubmit={formik.handleSubmit}>
          <Grid container spacing={4}>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your email
              </Typography>
              <TextField
                  label="Email *"
                  variant="outlined"
                  name={'email'}
                  fullWidth
                  value={formik.values.email}
                  onChange={formik.handleChange}
                  error={formik.touched.email && Boolean(formik.errors.email)}
                  helperText={formik.touched.email && formik.errors.email}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your firstname
              </Typography>
              <TextField
                  label="Firstname *"
                  variant="outlined"
                  name={'firstName'}
                  fullWidth
                  value={formik.values.firstName}
                  onChange={formik.handleChange}
                  error={formik.touched.firstName && Boolean(formik.errors.firstName)}
                  helperText={formik.touched.firstName && formik.errors.firstName}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your lastName
              </Typography>
              <TextField
                  label="Lastname *"
                  variant="outlined"
                  name={'lastName'}
                  fullWidth
                  value={formik.values.lastName}
                  onChange={formik.handleChange}
                  error={formik.touched.lastName && Boolean(formik.errors.lastName)}
                  helperText={formik.touched.lastName && formik.errors.lastName}
              />
            </Grid>
            <Grid item xs={12}>
              <Typography variant={'subtitle2'} sx={{marginBottom: 2}}>
                Enter your birthdate
              </Typography>
              <LocalizationProvider dateAdapter={AdapterMoment}>
                <DatePicker
                    label="Birthdate"
                    value={birthDate}
                    onChange={(newValue) => {
                      setBirthDate(newValue);
                    }}
                    renderInput={(params) => <TextField {...params} variant={"outlined"} fullWidth required/>}
                />
              </LocalizationProvider>
            </Grid>
            <Grid item xs={12}>
              <Typography variant="subtitle2" gutterBottom>
                Fields that are marked with * sign are required.
              </Typography>
              <Grid container spacing={2}>
                <Grid item>
                  <Button
                      size="large"
                      variant="contained"
                      color="primary"
                      type={"submit"}
                  >
                    Save
                  </Button>
                </Grid>
                <Grid item>
                  <Button size="large" variant="contained" color="secondary" onClick={toggleEditDrawer(false)}>
                    Cancel
                  </Button>
                </Grid>
              </Grid>
            </Grid>
          </Grid>
        </form>
    );
  }

  return (
      <Container maxWidth={"md"}>
        <Box sx={{margin: 2}}>
          {pageError && <Alert severity="error">{pageError}</Alert>}

          <Box marginBottom={4}>
            <Typography
                sx={{
                  textTransform: 'uppercase',
                  fontWeight: 'medium',
                }}
                gutterBottom
                color={'text.secondary'}
            >
              Create User
            </Typography>
            <Typography color="text.secondary">
              Enter the details
            </Typography>
          </Box>
        </Box>
        {user && renderForm()}
        <Footer></Footer>
      </Container>
  );
}

export default UserDetail;

代码formik.setValues导致重新渲染错误太多。知道如何解决吗?谢谢

你永远不应该在渲染过程中调用诸如 formik.setValues 之类的副作用。这将导致另一个渲染,然后再次调用它。 相反,在 useEffect.

中做这样的事情

所以,例如

useEffect(() => {
    formik.setValues({
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email
    });
}, [user])

每次 user 更改时都会调用 setValues,但不会超出此范围。

@phry 在这种情况下,我们不需要将 formik conts 放在 useEffect 依赖项下吗?如果是这样,它会导致同样的问题,还是渲染循环?

另一个修复方法是将表单用作组件并在其中初始化 formik(这相当于您当前的 renderForm 函数:

const Page: NextPage = () => {
 const user = selectUser
 
 {...}
 
 // You can extract this component also outside to the current module
 const Form = ({onSubmit, user: {name, email}}) => (
   const formik = useFormik({
     initialValues: {
       name,
       email
     },
     validationSchema: validationSchema,
     onSubmit
   });

   return (
     <form>{...}</form>
   )
 )

 return (
   {...}
   {user && <Form user={user} onSubmit={onSubmit} />}
 )
}