If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOh man, I let a bug crawl into our app. When we delete a rep log, it goes away, but, yuck, we get a big error:
Unexpected end of JSON input
This comes from rep_log_api.js
. We call response.json()
... which works great when the response is actually JSON. But, our delete endpoint returns nothing.
To fix this, we could create two different functions: one that decodes JSON and one that doesn't. But, I'll just make our code a bit fancier so it doesn't explode.
Use return response.text()
: this returns a Promise where the data is the raw response content. Chain .then
and use an arrow function with a text
argument. Here, if text
, return JSON.parse(text)
, else empty quotes.
... lines 1 - 4 | |
.then(response => { | |
// decode JSON, but avoid problems with empty responses | |
return response.text() | |
.then(text => text ? JSON.parse(text) : '') | |
}); | |
... lines 10 - 36 |
Go over, refresh and... delete! Ok, much better.
We have this cool system where we can lift our cat 26 times and see this temporary success message. So, we might as well do the same thing when we delete. And this is easy! Inside of RepLogApp
, down in handleDeleteRepLog
, chain off the delete: .then()
, an arrow function, and this.setSuccessMessage()
: Item was Un-lifted.
... lines 1 - 76 | |
handleDeleteRepLog(id) { | |
deleteRepLog(id) | |
.then(() => { | |
this.setSuccessMessage('Item was Un-lifted!'); | |
}); | |
... lines 82 - 89 | |
} | |
... lines 91 - 122 |
Cool! Move back and try it! Success... message.
We could be satisfied with our loading & success message setup. But... if you want... we can get fancier! Right now, we delete the rep log state immediately, but we don't show the success message until after the AJAX call finishes. If you want that to feel more synchronized, we could move the setState()
call so that it fires only when the rep log is actually deleted.
... lines 1 - 77 | |
deleteRepLog(id) | |
.then(() => { | |
// remove the rep log without mutating state | |
// filter returns a new array | |
this.setState((prevState) => { | |
return { | |
repLogs: prevState.repLogs.filter(repLog => repLog.id !== id) | |
}; | |
}); | |
this.setSuccessMessage('Item was Un-lifted!'); | |
}); | |
... lines 90 - 122 |
But, we're trading problems. Refresh again. When you click delete, there's a slight pause before the user gets any feedback. I'll add a few more items to the list real quick so that we can keep deleting.
Anyways, here's an idea of how we could improve this: when the user clicks delete, let's immediately change the opacity on the row that's being deleted, as a sort of "loading" indication.
Go into RepLogList
: this is where we render the tr
elements. So, imagine if there were a field on each repLog
called isDeleting
. If there were, we could say style={}
, create an object, and set opacity
: if isDeleting
is true, use .3 else 1.
... lines 1 - 22 | |
return ( | |
... line 24 | |
{repLogs.map((repLog) => ( | |
<tr | |
... lines 27 - 29 | |
style={{ | |
opacity: repLog.isDeleting ? .3 : 1 | |
}} | |
> | |
... lines 34 - 42 | |
))} | |
... lines 44 - 55 | |
); | |
... lines 57 - 67 |
This was easy. The interesting part of this problem is how we can add that new isDeleting
field. Well, it looks simple at first: at the top of handleDeleteRepLog
, before we call deleteRepLog()
, we want to set the state of one of our rep logs to have isDeleting: true
.
But... hmm... this is tricky. First, we need to find the one rep log by its id. Then, we need to set this flag, but without mutating that object or the array that it's inside of! Woh!
Here's the trick: use this.setState()
, but pass it an arrow function with the prevState
arg. We're doing this because our new state will depend on the old state. Return the new state we want to set, which is the repLogs
key.
... lines 1 - 76 | |
handleDeleteRepLog(id) { | |
this.setState((prevState) => { | |
return { | |
... lines 80 - 86 | |
}; | |
}); | |
... lines 89 - 137 |
To not mutate the state, we basically want to create a new array, put all the existing rep logs inside of it, and update the one rep log... um... without actually updating it. Sheesh.
This is another one of those moments where you can understand why React can be so darn hard! But, the fix is easy, and it's an old friend: map! Use prevState.repLogs.map()
with a repLog
argument to the arrow function.
... lines 1 - 77 | |
this.setState((prevState) => { | |
return { | |
repLogs: prevState.repLogs.map(repLog => { | |
... lines 81 - 85 | |
}) | |
}; | |
}); | |
... lines 89 - 137 |
The map function will return a new array, so that handles part of the problem. Inside, if repLog.id !==
the id
that's being deleted, just return repLog
. And finally, we need to basically "clone" this last rep log and set the isDeleting
flag on the new object. The way to do that is with return Object.assign()
passing it an empty object, repLog
, then the fields to update: isDeleting: true
.
... lines 1 - 79 | |
repLogs: prevState.repLogs.map(repLog => { | |
if (repLog.id !== id) { | |
return repLog; | |
} | |
return Object.assign({}, repLog, {isDeleting: true}); | |
}) | |
... lines 87 - 137 |
As I mentioned earlier, Object.assign()
is like array_merge
in PHP: the 3rd argument is merged into the second, and then that's merged into the first. The key is the strange first argument: the empty object. Thanks to that, we're creating a new object, and then all the data is merged into it. The repLog
is not modified.
Phew! But... awesome! We've now learned how to add to an array, remove from an array, and even change an object inside an array, all without mutation. If your state structure is deeper than a simple object inside an array, it's probably too deep. In other words, you now know how to handle the most common, tough, state-setting situations.
Let's temporarily add a return statement below so we can really see if this is working. Ok, move over and refresh! Hit delete: that looks awesome! Our update worked perfectly.
Go back and remove the return
.
Hey plashenkov!
Good tip! The "Object Rest/Spread" is new to ES2018, so wasn't *quite* available when I recorded this (without some extra Babel config). But I believe it should be available now. It's *much* nicer - we talk about it a few chapters from now ;) https://symfonycasts.com/sc...
Cheers!
Yeah, already found that you speak about it later. Just finished the tutorial. It's awesome, thank you!
// composer.json
{
"require": {
"php": "^7.2.0",
"ext-iconv": "*",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/doctrine-bundle": "^1.6", // 1.9.1
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.3
"doctrine/doctrine-fixtures-bundle": "~3.0", // 3.0.2
"doctrine/doctrine-migrations-bundle": "^1.2", // v1.3.1
"doctrine/orm": "^2.5", // v2.7.2
"friendsofsymfony/jsrouting-bundle": "^2.2", // 2.2.0
"friendsofsymfony/user-bundle": "dev-master#4125505ba6eba82ddf944378a3d636081c06da0c", // dev-master
"sensio/framework-extra-bundle": "^5.1", // v5.2.0
"symfony/asset": "^4.0", // v4.1.4
"symfony/console": "^4.0", // v4.1.4
"symfony/flex": "^1.0", // v1.17.6
"symfony/form": "^4.0", // v4.1.4
"symfony/framework-bundle": "^4.0", // v4.1.4
"symfony/lts": "^4@dev", // dev-master
"symfony/monolog-bundle": "^3.1", // v3.3.0
"symfony/polyfill-apcu": "^1.0", // v1.9.0
"symfony/serializer-pack": "^1.0", // v1.0.1
"symfony/swiftmailer-bundle": "^3.1", // v3.2.3
"symfony/twig-bundle": "^4.0", // v4.1.4
"symfony/validator": "^4.0", // v4.1.4
"symfony/yaml": "^4.0", // v4.1.4
"twig/twig": "2.10.*" // v2.10.0
},
"require-dev": {
"symfony/debug-pack": "^1.0", // v1.0.6
"symfony/dotenv": "^4.0", // v4.1.4
"symfony/maker-bundle": "^1.5", // v1.5.0
"symfony/phpunit-bridge": "^4.0", // v4.1.4
"symfony/web-server-bundle": "^4.0" // v4.1.4
}
}
// package.json
{
"dependencies": {
"@babel/plugin-proposal-object-rest-spread": "^7.12.1" // 7.12.1
},
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.12.5
"@symfony/webpack-encore": "^0.26.0", // 0.26.0
"babel-plugin-transform-object-rest-spread": "^6.26.0", // 6.26.0
"babel-plugin-transform-react-remove-prop-types": "^0.4.13", // 0.4.13
"bootstrap": "3", // 3.3.7
"copy-webpack-plugin": "^4.4.1", // 4.5.1
"core-js": "2", // 1.2.7
"eslint": "^4.19.1", // 4.19.1
"eslint-plugin-react": "^7.8.2", // 7.8.2
"font-awesome": "4", // 4.7.0
"jquery": "^3.3.1", // 3.3.1
"promise-polyfill": "^8.0.0", // 8.0.0
"prop-types": "^15.6.1", // 15.6.1
"react": "^16.3.2", // 16.4.0
"react-dom": "^16.3.2", // 16.4.0
"sass": "^1.29.0", // 1.29.0
"sass-loader": "^7.0.0", // 7.3.1
"sweetalert2": "^7.11.0", // 7.22.0
"uuid": "^3.2.1", // 3.4.0
"webpack-notifier": "^1.5.1", // 1.6.0
"whatwg-fetch": "^2.0.4" // 2.0.4
}
}
6:30, a note: instead of Object.assign we can use the spread operator:
return {...item, newKey: newValue}