Frontend Serverless with React and GraphQL

Start at the beginning and work your way through this project. The code for each step as well as the finished project can be found in the Github repository.


38. Add Update Recipe Mutation

Objective: Add a mutation for the UpdateRecipe component to allow the changes to be saved to the backend.

Now that we have a fancy form that's fetching the current state of an already existing recipe, let's make an update recipe mutation. We first need to call the useMutation hook in the component and then use it in the initiateUpdateRecipe function. You'll notice that prior to calling the mutation however, we call a different function called createUpdateObj first. This is a function that will strip out all the things that did not change so we are only sending a mutation of what we changed, not everything. This makes the request snappier and is just in general best practice.

components/UpdateRecipe.tsx

import { useQuery, useMutation } from '@apollo/react-hooks';
import { recipeGraphQL } from '../graphql/queries/recipe';
import { submitForm } from '../utils/submitForm';
import { useState } from 'react';
import * as _ from 'lodash';
import { Form, Row, Col, Button } from 'antd';
import { GenerateInput, GenerateTextInput } from './GenerateFields';
import { GenerateIngredients } from './GenerateIngredients';
import { Loading } from './notify/Loading';
import { createUpdateObj } from '../utils/createUpdateObj';
import { updateRecipeGraphQL } from '../graphql/mutations/updateRecipe';
export const UpdateRecipe = ({ id }) => {
const { loading: isQueryLoading, data, error } = useQuery(recipeGraphQL, {
variables: { where: { id } },
});
const [updateRecipeMutation, { loading: updateRecipeLoading }] = useMutation(
updateRecipeGraphQL,
);
const [recipeState, setRecipeState] = useState({ isQueryLoading: true });
const initiateUpdateRecipe = () => {
const updateObj = createUpdateObj(data, inputs);
return updateRecipeMutation({
refetchQueries: [{ query: recipeGraphQL, variables: { where: { id } } }],
variables: {
data: {
...updateObj,
},
where: { id },
},
});
};
const {
inputs,
handleInputChange,
handleAddIngredient,
handleDeleteIngredient,
handleDropdownChange,
handleUpdate,
setInputs,
} = submitForm(
{
title: '',
description: '',
content: '',
ingredients: [],
},
initiateUpdateRecipe,
);
if (!isQueryLoading && recipeState.isQueryLoading) {
const { __type, ...loadedRecipe } = _.get(data, 'recipe');
setInputs((state) => ({ ...state, ...loadedRecipe }));
setRecipeState((state) => ({ ...state, isQueryLoading }));
}
if (!data) return <Loading />;
return (
<Form onFinish={handleUpdate}>
<GenerateInput
name="title"
value={inputs.title}
handleInputChange={handleInputChange}
/>
<GenerateInput
name="description"
value={inputs.description}
handleInputChange={handleInputChange}
/>
<GenerateTextInput
name="content"
value={inputs.content}
handleInputChange={handleInputChange}
/>
<GenerateIngredients
names={['amount', 'unit', 'type']}
values={inputs.ingredients}
handleAddIngredient={handleAddIngredient}
handleDeleteIngredient={handleDeleteIngredient}
handleInputChange={handleInputChange}
handleDropdownChange={handleDropdownChange}
/>
<Row>
<Col span={16} />
<Col span={4}>
<Form.Item label="Update Recipe">
<Button
disabled={isQueryLoading || updateRecipeLoading}
type="primary"
htmlType="submit"
>
Update Recipe
</Button>
</Form.Item>
</Col>
</Row>
</Form>
);
};

This is what the createUpdateObj function looks like. You can see that we pass in our newObj as well as the data object from the recipe query. We map over all of the keys in the newObj, comparing them to the current value. It is only when we see a change that we will add it to the updateObj which is what ultimately gets returned from this function.

utils/createUpdateObj.ts

import * as _ from 'lodash';
export const createUpdateObj = (data, newObj) => {
const updateObj = {};
_.mapKeys(newObj, (value, key) => {
const oldValue = _.get(data, `recipe.${key}`);
if (!_.isEqual(oldValue, value)) {
updateObj[key] = value;
}
});
return updateObj;
};

Next, we need to create a handleUpdate function in our submitForm hook that will get excuted when someone presses the update button. How this works is that the initiateUpdateRecipe calls the mutation and gets back the updated record which it returns from the function. Since we call that function from within our handleUpdate function by calling callback(), we actually will have access to the update mutation response that has the new data. We take that data and set all the fields in the form to the new values that we got back, and then the user will be able to see all the current data.

Note that most of the time, all the fields will already have the current data that it gets from the updateRecipe mutation since we were just updating all the fields to our liking. The reason why we are taking this added step is that if somebody else was updating this same record we'd want to make sure that we were getting the current latest state. This particular situation wouldn't happen in this application since we will soon make it so that people can only edit their own recipes for security purposes, but it is still best practice to reconcile forms after running an update mutation with the current source of truth for this record on the backend.

utils/submitForm.ts

import { useState } from 'react';
import * as _ from 'lodash';
export const submitForm = (initialValues, callback) => {
const [inputs, setInputs] = useState(initialValues);
const handleInputChange = (event) => {
event.persist();
setInputs((inputs) => {
const newInputs = _.cloneDeep(inputs);
_.set(newInputs, event.target.name, event.target.value);
return newInputs;
});
};
const handleDropdownChange = (event) => {
setInputs((inputs) => {
const newInputs = _.cloneDeep(inputs);
_.set(newInputs, event.item.props.title, event.key);
return newInputs;
});
};
const handleAddIngredient = (event) => {
event.persist();
setInputs((inputs) => {
const sortedIngredients = _.sortBy(inputs.ingredients, ['key']);
const key =
sortedIngredients.length > 0
? sortedIngredients[sortedIngredients.length - 1].key + 1
: 0;
return {
...inputs,
ingredients: _.concat(inputs.ingredients, [
{ key, amount: '', unit: '-', type: '' },
]),
};
});
};
const handleDeleteIngredient = (event) => {
event.persist();
const position = parseInt(event.target.name);
setInputs((inputs) => ({
...inputs,
ingredients: _.filter(
inputs.ingredients,
(_i, index) => index !== position,
),
}));
};
const handleUpdate = async () => {
const updatedResult = await callback();
const { updateRecipe } = updatedResult.data;
const { content, description, status, title, ingredients } = updateRecipe;
setInputs(() => ({ content, description, status, title, ingredients }));
};
const handleSubmit = () => {
callback();
setInputs(() => ({ ...initialValues }));
};
return {
inputs,
setInputs,
handleSubmit,
handleUpdate,
handleInputChange,
handleAddIngredient,
handleDeleteIngredient,
handleDropdownChange,
};
};

One final touch, is that you might have noticed that we had a refetchQueries in our update recipe mutation to make sure we pull the latest recipes. Let's also go ahead and do this for the create recipe component so we are super sure that we'll have the latest data after performing that action as well. This will ensure that the recipes page will always have the new recipe that we created. Just add the refetchQueries line below to the <CreateRecipe> page you already have:

components/CreateRecipe.tsx

// Code above has been omitted
createRecipeMutation({
refetchQueries: [{ query: recipesGraphQL }],
variables: {
data: {
...inputs,
owner,
},
},
});
// Code below has been omitted