Maramal
Maramal

Reputation: 3466

React component and Apollo Client does not work "in real time"

I am trying to build a user system but I am getting confused with something since it does not work in real time.

I have created an example Sandbox to show my "issue" and my code. I didn't add any kind of validation stuff, it is just for example purposes.

Some of the issues (I see) are:

  1. Button actions trigger on second click
  2. Data won't refresh after created / deleted.

This is the <UsersPage /> component:

import React, { Fragment, useState, useEffect } from "react";
import { useMutation, useLazyQuery } from "@apollo/react-hooks";
import { ADD_USER, LIST_USERS, DELETE_USER } from "../../../config/constants";
import { useSnackbar } from "notistack";
import {
  Grid,
  Paper,
  TextField,
  Button,
  Typography,
  MenuItem,
  FormHelperText
} from "@material-ui/core";
import AddUserIcon from "@material-ui/icons/PersonAdd";
import { withStyles } from "@material-ui/core/styles";
import PropTypes from "prop-types";
import Table from "../../Table";

const styles = theme => ({
  grid: {
    margin: theme.spacing(3)
  },
  icon: {
    marginRight: theme.spacing(2)
  },
  form: {
    width: "100%",
    marginTop: theme.spacing(3),
    overflowX: "auto",
    padding: theme.spacing(2)
  },
  submit: {
    margin: theme.spacing(2)
  },
  container: {
    display: "flex",
    flexWrap: "wrap"
  },
  textField: {
    marginLeft: theme.spacing.unit,
    marginRight: theme.spacing.unit
  },
  root: {
    width: "100%",
    marginTop: theme.spacing(3),
    overflowX: "auto",
    padding: theme.spacing(2)
  },
  title: {
    margin: theme.spacing(2)
  },
  table: {
    minWidth: 700
  },
  noRecords: {
    textAlign: "center"
  },
  button: {
    margin: theme.spacing.unit
  }
});

const Users = props => {
  const [idState, setIdState] = useState(null);
  const [emailState, setEmailState] = useState("");
  const [passwordState, setPasswordState] = useState("");
  const [usersState, setUsersState] = useState([]);
  const [errorsState, setErrorsState] = useState({});
  const [loadingState, setLoadingState] = useState(false);
  const [addUser, addUserResponse] = useMutation(ADD_USER);
  const [loadUsers, usersResponse] = useLazyQuery(LIST_USERS);
  const [deleteUser, deleteUserResponse] = useMutation(DELETE_USER);
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => {
    loadUsers();

    if (usersResponse.called && usersResponse.loading) {
      setLoadingState(true);
    } else if (usersResponse.called && !usersResponse.loading) {
      setLoadingState(false);
    }

    if (usersResponse.data) {
      setUsersState(usersResponse.data.getUsers);
    }
  }, [usersResponse.called, usersResponse.loading, usersResponse.data]);

  function handleSubmit(e) {
    e.preventDefault();

    if (idState) {
    } else {
      addUser({
        variables: {
          email: emailState,
          password: passwordState
        }
      });
    }

    if (addUserResponse.called && addUserResponse.loading) {
      enqueueSnackbar("Creating user");
    }

    if (addUserResponse.error) {
      addUserResponse.error.graphQLErrors.map(exception => {
        const error = exception.extensions.exception;
        const messages = Object.values(error);
        enqueueSnackbar(messages[0], { variant: "error" });
      });
    }

    if (addUserResponse.data && addUserResponse.data.addUser) {
      enqueueSnackbar("user created", { variant: "success" });
      loadUsers();
    }
  }

  function handleEdit(user) {
    setIdState(user.id);
    setEmailState(user.email);
  }

  async function handleDelete(data) {
    if (typeof data === "object") {
      data.map(id => {
        deleteUser({ variables: { id } });
        if (deleteUserResponse.data && deleteUserResponse.data.deleteUser) {
          enqueueSnackbar("User deleted", { variant: "success" });
        }
      });
    } else {
      deleteUser({ variables: { id: data } });
      if (deleteUserResponse.data && deleteUserResponse.data.deleteUser) {
        enqueueSnackbar("User deleted", { variant: "success" });
      }
    }
  }

  function resetForm() {
    setIdState(null);
    setEmailState("");
  }
  const { classes } = props;

  return (
    <Fragment>
      <Grid container spacing={8}>
        <Grid item xs={3} className={classes.grid}>
          <Paper className={classes.form}>
            <Typography variant="h6" className={classes.title}>
              {idState ? `Edit user: ${emailState}` : "Create user"}
            </Typography>
            <form className={classes.container} onSubmit={handleSubmit}>
              <input type="hidden" name="id" value={idState} />
              <TextField
                className={classes.textField}
                label="E-mail address"
                type="email"
                variant="outlined"
                margin="normal"
                autoComplete="email"
                id="email"
                name="email"
                required={!idState}
                fullWidth
                onChange={e => setEmailState(e.target.value)}
                value={emailState}
                aria-describedby="email-error"
              />
              <FormHelperText id="email-error">
                {errorsState.email}
              </FormHelperText>
              <TextField
                className={classes.textField}
                label="Password"
                variant="outlined"
                margin="normal"
                autoComplete="password"
                id="password"
                name="password"
                required={!idState}
                type="password"
                fullWidth
                onChange={e => setPasswordState(e.target.value)}
                value={passwordState}
                aria-describedby="password-error"
              />
              <FormHelperText id="password-error">
                {errorsState.password}
              </FormHelperText>

              <Button
                variant="contained"
                color="primary"
                className={classes.submit}
                size="large"
                type="submit"
              >
                <AddUserIcon className={classes.icon} /> Save
              </Button>
              <Button
                variant="contained"
                color="secondary"
                className={classes.submit}
                type="button"
                onClick={resetForm}
              >
                <AddUserIcon className={classes.icon} /> Add new
              </Button>
            </form>
          </Paper>
        </Grid>
        <Grid item xs={8} className={classes.grid}>
          <Paper className={classes.root}>
            <Table
              data={usersState}
              className={classes.table}
              columns={{
                id: "ID",
                email: "E-mail address"
              }}
              classes={classes}
              title="Users"
              handleEdit={handleEdit}
              handleDelete={handleDelete}
              filter={true}
              loading={loadingState}
            />
          </Paper>
        </Grid>
      </Grid>
    </Fragment>
  );
};

Users.propTypes = {
  classes: PropTypes.object.isRequired
};

export default withStyles(styles)(Users);

In case you need more code, or editing:

Frontend sandbox: App / Code

Backend sandbox: App / Code

Any comments, suggestions or whatever will be appreciated.

Upvotes: 2

Views: 879

Answers (1)

Pedro Arantes
Pedro Arantes

Reputation: 5379

  1. Button actions trigger on second click

It's because you call

if (addUserResponse.called && addUserResponse.loading) {
  enqueueSnackbar("Creating user");
}

right after you call addUser. The state didn't change when you check if (addUserResponse.called && addUserResponse.loading), the state is the same state before calling addUser.

When you click on the second time, you have the state after the first click and this if

if (addUserResponse.data && addUserResponse.data.addUser) {
  enqueueSnackbar("user created", { variant: "success" });
  loadUsers();
}

is true.

Solution:

Create a useEffect to handle addUser state and remove the if clauses from handleSubmit

 useEffect(() => {
    if (!addUserResponse.called) {
      return;
    }

    if (addUserResponse.loading) {
      enqueueSnackbar("Creating user");
      return;
    }

    if (addUserResponse.error) {
      addUserResponse.error.graphQLErrors.map(exception => {
        const error = exception.extensions.exception;
        const messages = Object.values(error);
        enqueueSnackbar(messages[0], { variant: "error" });
      });
      return;
    }

    enqueueSnackbar("user created", { variant: "success" });
  }, [addUserResponse.called, addUserResponse.loading]);

 function handleSubmit(e) {
    e.preventDefault();

    if (idState) {
    } else {
      addUser({
        variables: {
          email: emailState,
          password: passwordState
        }
      });
    }
  }
  1. Data won't refresh after created / deleted.

You should update your cache after mutation because Apollo doesn't know if you're adding or deleting when you call a mutation.

Solution:

 const [addUser, addUserResponse] = useMutation(ADD_USER, {
    update: (cache, { data: { addUser } }) => {
      // get current data cache
      const cachedUsers = cache.readQuery({ query: LIST_USERS });

      // create new users
      const newUsers = [addUser, ...cachedUsers.getUsers];

      // save newUsers on cache
      cache.writeQuery({
        query: LIST_USERS,
        data: {
          getUsers: newUsers
        }
      });
    }
  });

The same is true for delete user, expect newUsers will have current users filtered:

const [deleteUser, deleteUserResponse] = useMutation(DELETE_USER, {
    update: (cache, { data: { deleteUser } }) => {
      const cachedUsers = cache.readQuery({ query: LIST_USERS });

      // NOTE: this didn't work because deleteUser return true instead user.
      // I'd suggest change your backend and deleteUser return user id to
      // be able to perform this filter.
      const newUsers = cachedUsers.getUsers.filter(
        ({ id }) => id !== deleteUser.id
      );

      cache.writeQuery({
        query: LIST_USERS,
        data: {
          getUsers: newUsers
        }
      });
    }
  });

Note 1:

You don't need to call loadUsers more than one time. Because you update the cache when you perform a mutation, your data will always be the most recent. Because of that I'd call loadUsers this way:

 useEffect(() => {
    loadUsers();
  }, []);

  useEffect(() => {
    if (usersResponse.called && usersResponse.loading) {
      setLoadingState(true);
    } else if (usersResponse.called && !usersResponse.loading) {
      setLoadingState(false);
    }
  }, [usersResponse.called, usersResponse.loading]);

Note 2

You don't need to create a state for users, you already have one from usersResponse.data.getUsers, but it's your preference. In my case, I removed const [usersState, setUsersState] = useState([]); and added

const users =
    usersResponse.data && usersResponse.data.getUsers
      ? usersResponse.data.getUsers
      : [];

to pass to the table.


Edit October 10, 2019

The main change I did was creating a mutation called batchDeleteUsers that delete multiple users in a single call.

Updating server

I've made some changes to the server to get the app working. First, deleteUser returns User and I've created a mutation called batchDeleteUsers.

My current mutation schema:

type Mutation {
  addUser(email: String!, password: String!): User
  deleteUser(id: String!): User
  batchDeleteUsers(ids: [String!]!): [User]
}

My current resolvers:

deleteUser: (root, { id }, context) => {
  const user = USERSDB.find(user => user.id === id);
  USERSDB = USERSDB.filter(user => user.id !== id);
  return user;
},
  batchDeleteUsers: (root, { ids }, context) => {
  const users = USERSDB.filter(user => ids.includes(user.id));
  USERSDB = USERSDB.filter(user => !ids.includes(user.id));
  return users;
}

Updating App 1

Instead using useLazyQuery and calling it inside a useEffect, I'm using useQuery. This way we don't need to perform query inside useEffect, it's triggered when component initializes.

const usersResponse = useQuery(LIST_USERS);

Updating App 2

Below is how I create deleteUser and batchDeleteUsers mutations.

const [deleteUser, deleteUserResponse] = useMutation(DELETE_USER, {
  update: (cache, { data: { deleteUser } }) => {
    const cachedUsers = cache.readQuery({ query: LIST_USERS });

    const newUsers = cachedUsers.getUsers.filter(
      ({ id }) => id !== deleteUser.id
    );

    cache.writeQuery({
      query: LIST_USERS,
      data: {
        getUsers: newUsers
      }
    });
  }
});
const [batchDeleteUsers, batchDeleteUsersResponse] = useMutation(
  BATCH_DELETE_USERS,
  {
    update: (cache, { data: { batchDeleteUsers } }) => {
      const cachedUsers = cache.readQuery({ query: LIST_USERS });

      const newUsers = cachedUsers.getUsers.filter(({ id }) => {
        return !batchDeleteUsers.map(({ id }) => id).includes(id);
      });

      cache.writeQuery({
        query: LIST_USERS,
        data: {
          getUsers: newUsers
        }
      });
    }
  }
);

Updating App 3

This is how I handle the delete users' mutations lifecycle.

useEffect(() => {
  if (!deleteUserResponse.called) {
    return;
  }

  if (deleteUserResponse.loading) {
    enqueueSnackbar("Deleting user");
    return;
  }

  if (deleteUserResponse.error) {
    deleteUserResponse.error.graphQLErrors.map(exception => {
      const error = exception.extensions.exception;
      const messages = Object.values(error);
      enqueueSnackbar(messages[0], { variant: "error" });
    });
    return;
  }

  enqueueSnackbar("user deleted", { variant: "success" });
}, [deleteUserResponse.called, deleteUserResponse.loading]);

useEffect(() => {
  if (!batchDeleteUsersResponse.called) {
    return;
  }

  if (batchDeleteUsersResponse.loading) {
    enqueueSnackbar("Deleting users");
    return;
  }

  if (batchDeleteUsersResponse.error) {
    batchDeleteUsersResponse.error.graphQLErrors.map(exception => {
      const error = exception.extensions.exception;
      const messages = Object.values(error);
      enqueueSnackbar(messages[0], { variant: "error" });
    });
    return;
  }

  enqueueSnackbar("users deleted", { variant: "success" });
}, [batchDeleteUsersResponse.called, batchDeleteUsersResponse.loading]);

Updating App 4

Finally, this is how I handle delete users.

function handleDelete(data) {
  if (typeof data === "object") {
    batchDeleteUsers({ variables: { ids: data } });
  } else {
    deleteUser({ variables: { id: data } });
  }
}

My server sandbox: code

My frontend sandbox: code

Upvotes: 1

Related Questions