See Our Public Workshops:
Last week we finished migrating our React Training Blog off Medium and into here. As I was doing the migration I thought, Michael and Ryan sure do have a lot of "claps" for their posts, would be a shame to lose those.
So I did what any developer would do, I re-created it.
This article tells a little bit of a story about how I got to the final code. If you want to, you can just skip to the finished code.
First, my requirements were:
- Let the user click as many times as they want, similar to Medium (although I found out later that Medium might allow only 50 claps per person, but whatever ours is unlimited)
- Don't require authentication like Medium does. It's just an extra barrier to prevent someone from clapping but also we don't have other features yet that require it so let's just make this lightweight for now.
- Debounce the network requests so when someone is rapidly clicking the clap button, we don't send off a new request for each click.
#1 and #2 are somewhat related. If we had authentication or some sort of IP tracking we could limit how much people can clap, but this is "just for fun" and isn't meant to be taken too seriously as a metric for anything.
The <BlogClaps>
Component
This is approximately what I came up with at first, but there's some problems we'll discuss:
import React, { useState } from 'react'import { debounce } from 'lodash'function BlogClaps() {const [claps, setClaps] = useState(0)const [queueClaps, setQueueClaps] = useState(0)const saveClaps = debounce((claps) => {// This is where the network request would be, when it// resolves we would set `claps` to be whatever the network// says it is, and then reset `queueClaps`. But for now// we're skipping that part and just setting claps manually:setClaps(claps)setQueueClaps(0)}, 700) // Debounce for 700msfunction clap() {setQueueClaps(queueClaps + 1)saveClaps(queueClaps + 1)}return (<div><button onClick={clap}>Clap</button> {queueClaps}/{claps}</div>)}render(<BlogClaps />, document.getElementById('root'))
For now we'll be skipping the network part and just get the basic functionality.
If you're not super comfortable with hooks yet (and I'm still getting there, it takes some getting used to), I recommend this article by Dan Abramov. It's long, but it will most likely double your React Hooks game. Make sure you are somewhat comfortable before continuing this article because we're about to get into the weeds.
Debouncing
In case you're not familiar, debouncing is a technique of ensuring a function cannot be called in succession too rapidly. We are making a saveClaps
function that when called will wait for 700 milliseconds to see if it's called again. If it is called again within 700ms then it starts the time over and waits again. It does this until 700ms has passed with no additional calls. Then the callback is called just once. The end result for us means that if someone claps many times quickly, we will wait for a pause in their clapping before we do the network request.
From a state standpoint, I need to keep track of how many claps the user has been accumulating for the eventual debounced network request. Then I need to also keep track of how many claps there are in total. This is why each time the button is clicked the queuedClaps
gets incremented immediately and then we call saveClaps()
which eventually moves queuedClaps
to claps
and resets the queue.
So what's the Problem?
Now that we're writing hooks in React, we need to change our mindset a little. Remember that our BlogClaps()
component is a function will be called over and over (for state changes among other things). Each time it's called we're re-assigning saveClaps
with a new debounced function. To fix this, I tried using a ref since they are like "instance variables" that don't change over time:
const saveClaps = useRef(debounce((claps) => {setClaps(claps)setQueueClaps(0)}, 700))function clap() {const newClaps = queueClaps + 1setQueueClaps(newClaps)saveClaps.current(newClaps)}
Just remember that whatever you pass into useRef
is available as the .current
property of the ref returned. So in this case, saveClaps.current()
is the debounced function.
See a working version of what we have so far on Stackblitz
Converting to useRef
works, almost
It technically works, although we're calling debounce()
with each time our component re-renders. The current value of a ref doesn't change so the multiple calls to debounce()
are not too harmful, but it's still a little annoying to have this going on.
Taking a step back, do we need lodash?
After some consideration and trying to fix the multiple calls to lodash (and talking to Ryan), we thought about abandoning it for a simple timeout solution instead.
What if BlogClaps
looked like this:
function BlogClaps() {const [claps, setClaps] = useState(0)const [queueClaps, setQueueClaps] = useState(0)const timeoutRef = useRef(null)function clap() {clearTimeout(timeoutRef.current)const newClaps = queueClaps + 1setQueueClaps(newClaps)timeoutRef.current = setTimeout(() => {// This is where the network request would be, when it// resolves we would set `claps` to be whatever the network// says it is, and then reset `queueClaps`. But for now// we're skipping that part and just setting claps manually:setClaps(newClaps)setQueueClaps(0)}, 700)}return (<div><button onClick={clap}>Clap</button> {queueClaps}/{claps}</div>)}
This works too. Now we're just incrementing queueClaps
for each click and then after 700ms we'll sync to the server. To prevent overlapping timeouts, we'll just clear the current timeout each time clap()
is called.
Although, persisting the claps to the server is really a side-effect of clapping and it could also be decoupled from the clap function. When we clap, we just want to save our claps in state and then let a side-effect function deal with the synchronization.
Let's try useEffect
to manage our setTimeout
:
function BlogClaps() {const [claps, setClaps] = useState(0)const [queueClaps, setQueueClaps] = useState(0)useEffect(() => {if (queueClaps > 0) {const timeout = setTimeout(() => {setClaps(queueClaps)setQueueClaps(0)}, 700)return () => clearTimeout(timeout)}}, [queueClaps])function clap() {setQueueClaps(queueClaps + 1)}return (<div><button onClick={clap}>Clap</button> {queueClaps}/{claps}</div>)}
See a working version at Stackblitz
Final with Network Request
Next we'll do the real network requests with our helper functions we wrote for Firebase. Notice that we're also passing a URL slug for the blog post so we know where to save to:
function BlogClaps({ slug }) {const [loading, setLoading] = useState(true)const [claps, setClaps] = useState(0)const [queueClaps, setQueueClaps] = useState(0)useEffect(() => {// When the component loads, get the current clap countgetBlogPostClaps(slug).then((claps) => {setLoading(false)// Set the clap countsetClaps(claps)})}, [slug])// This effect gets called anytime the queueClaps gets changeduseEffect(() => {if (queueClaps > 0) {// Create a timeout if queueClaps was bigger than 0const timeout = setTimeout(() => {// After 700ms, send the network request and then// update the claps based on the network, reset// queueClapsaddBlogPostClaps(slug, queueClaps).then((claps) => {setClaps(claps)setQueueClaps(0)})}, 700)return () => clearTimeout(timeout)}}, [queueClaps])function clap() {setQueueClaps(queueClaps + 1)}// Notice the changes to the JSX too:return (<div><button onClick={clap}>{loading ? 'Loading...' : `${(claps + queueClaps).toLocaleString()} claps`}</button></div>)}
See the final version on StackBlitz
Now it's up to you to design it!
Instead of having comments here in our blog, we've tweeted about this post so you can comment there if you wish.
View on Twitter