The issue
When I first put this site on Gatsby, I found adding comments tricky. I used this blog post, Dynamic Comments with Gatsby and WordPress, tweaked it a bit and shoved it in. I only used it for the comments form because I wasn’t worried about rebuilding – I really just wanted to see how it worked.
But I didn’t understand it and I didn’t like wrapping my site in an ApolloClient, it seemed to slow it down a bit, and it all seemed a bit much to get not a lot of comments.
Enter this week as I’m doing something else and came across this page in the Gatsby docs, Build time and client runtime data fetching, and read about using fetch
. Fetch! I know how to use that. Well… I know how to use that with the WordPress REST API. And you know what? That will do.
A high level overview
I’m keeping the posts the same as before but dynamically loading comments and handling comment submissions using fetch
and the WordPress REST API.
When getting the comments, the fetch
requests are wrapped in useEffect
to get them after the page load, making sure to unsubscribe to the fetch
Promise, and I’m using useState
to set the comment list.
Submitting the comment is handled in an onclick
function which sends a POST request to the WordPress comments endpoint. I allow anonymous comments (and don’t have or want logged in users submitting comments) so there’s a tiny plugin with Gatsby helpers to which I’ve added a filter to allow anonymous comments which are off by default.
The details
There are a few components used.
Comments
: parent component for all comment related things (Comments gist here)CommentsList
: the list of all comments, usesComment
(CommentsList gist here)Comment
: the individual comment (Comment gist here)CommentCount
: the number of published comments on the post (CommentCount gist here)CommentForm
: the form for comment submission (CommentForm gist here)
I’ll explain the ones with the requests in detail here, Comments
and CommentForm
.
The Comments component
The entire component is at the end of this. It uses fetch
as an async function in useEffect
. This async function is immediately called to get the comments from the site. It’s done this way because useEffect
won’t take an async function like useEffect( async () => ...
so this needs to be inside the function it does use eg :
const [things, setThings] = useState([])
useEffect( () => {
// this is the async function which will return a promise to get the data.
async function getThingsFunction () {
const things = await fetch( endpoint )
. then( (res) => {
// do stuff, return the response in the correct form
return data
}
return things
}
// (cleanup not included here, keep reading)
// call the async function to set the state
getThingsFunction().then( (data) => { setThings( data ) })
The fetch
response in the Comments component sets the comments
state which will either be an array of the comments or, if there aren’t any or there was an error, an empty array.
The request in this function is a simple GET request to the comments endpoint with a query variable post=postId
to tell it which post’s comments it should get. This endpoint only sends the published comments and is public by default. The comments come as an array of objects. The WP REST API developer handbook is not the best, but you can see the schema here – it will be the “view” context that is returned.
There is a cleanup function in useEffect
to avoid the “… To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function” error. The explanation for this I used it here in “Cancelling a Promise with React.useEffect” which explains things better than I could.
/** @jsx jsx */
import { jsx } from 'theme-ui'
import React, { useState, useEffect } from 'react'
import CommentsCount from './CommentsCount'
import CommentsList from './CommentsList'
import CommentForm from './CommentForm'
const Comments = ({ post, location, wordPressUrl }) => {
// The comments endpoint, hardcoded in, I have no shame.
const commentsEndpoint = `${wordPressUrl}/wp-json/wp/v2/comments`
// Each comments component is attached to a post. This is the endpoint to get all comments for that post.
const postCommentsEndpoint = `${commentsEndpoint}?post=${post.postId}`
// Setting up state to handle the incoming comments.
const [comments, setComments] = useState(false)
// Do this after the page loads.
useEffect(() => {
// Need to wait for the comments!
async function getPostComments() {
const getComments = await fetch(postCommentsEndpoint)
.then(response => {
// Check the response status, if it's not 200 throw an error.
if (response.status !== 200) {
throw Error(response.message)
}
return response
})
.then(response => {
return response.json()
})
.catch(error => {
// Catch the error and fail quietly, no one needs to know.
console.log(error)
return []
})
return getComments
}
// See https://juliangaramendy.dev/use-promise-subscription/. This fixes the "To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function" error.
let isSubscribed = true
// Only get all the comments if this is a single page. Otherwise it runs in archive lists and that's not necessary.
// Although! This means that until the site is rebuilt, comment counts on archive post lists will be incorrect after someone successfully
// submits a comments. Currently I moderate all comments and really, there are almost none, so I'm ok with this for now.
// If you wanted it up to date, you could tweak CommentsCount to use comments.length and take of the "single" check so it does
// the request on archive pages too
if (location === 'single') {
getPostComments().then(postComments => {
if (isSubscribed) {
setComments(postComments)
}
})
}
return () => (isSubscribed = false)
}, [])
return (
<>
{location === 'single' ? (
<>
<CommentsList post={post} comments={comments} />
<CommentForm post={post} commentsEndpoint={commentsEndpoint} />
</>
) : (
<CommentsCount post={post} />
)}
</>
)
}
export default Comments
The CommentsForm component
This is what it says on the tin: a form for submitting comments. The form is a bog standard form. It used to be component based but I updated it to use functions and hooks a while ago and love them! I highly recommend this if you need something satisfying to do and want to sit on your computer doing it.
BEFORE YOU START
This component requires a filter in the WordPress install which allows the REST API to accept anonymous submissions. This functionality is off by default. I’m using a very simple plugin which consists of a file with this in it. (I’ll add to this plugin at some point, there is functionality elsewhere that I use which should be in it.)
<?php
/**
* Plugin Name: MJJ Gatsby Bits and Bobs
* Description: Adding little things to make the Gatsby site.
* Author: JJ
* Text Domain: mjj-gatsby
* Domain Path: /languages
* Version: 0.1.0
*
* @package mjjgatsbytest
*/
namespace Tharshetests\MjjGatsbyTest;
add_filter( 'rest_allow_anonymous_comments', '__return_true' );
BACK TO THE COMPONENT
The bit that handles the submission is this:
// This handles sending the comment to the WordPress install.
const createComment = () => {
// Trying to avoid double clicks here.
if (commentStatus === 'loading') {
return // don't send this twice.
}
// This is a POST request to the comments endpoint. The body is sent as a JSON string.
// Once the response is received, we set the comment status accordingly.
fetch(commentsEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
author_email: email,
author_name: userName,
post: postId,
content: message,
}),
}).then(response => {
if (response.status === 201) {
setCommentStatus('success')
} else {
setCommentStatus('error')
}
})
}
The body is sent as a JSON string with the Content-Type header as application/json
. It’s sent to the comments endpoint and the ID of the post for which it’s a comment is in the body. All of the parameters available are in the developer handbook.
The entire component is below. I don’t do any validation of the form fields, it’s very bare bones.
/** @jsx jsx */
import { jsx, Styled } from 'theme-ui'
import { useState } from 'react'
const CommentForm = ({ post, commentsEndpoint }) => {
const postId = post.postId
const [userName, setUserName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [commentStatus, setCommentStatus] = useState(false)
// This handles sending the comment to the WordPress install.
const createComment = () => {
// Trying to avoid double clicks here.
if (commentStatus === 'loading') {
return // don't send this twice.
}
// This is a POST request to the comments endpoint. The body is sent as a JSON string.
// Once the response is received, we set the comment status accordingly.
fetch(commentsEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
author_email: email,
author_name: userName,
post: postId,
content: message,
}),
}).then(response => {
if (response.status === 201) {
setCommentStatus('success')
} else {
setCommentStatus('error')
}
})
}
// Renders the comment form elements.
const renderCommentForm = (
// This is using Styled components. (I keep playing around with the styles, it's not my strong suit.)
// When the form is submitted, the comment status is set to "loading", then the submission response will update it.
// The form inputs are easy with hooks! I updated this a little while ago and love them. 💖
// All the comments on this are up here otherwise they'll mess up the rendering.
<div>
<Styled.h3>Leave a comment</Styled.h3>
<Styled.p sx={{ a: { variant: `links.decorated` }, color: `text` }}>
Your email address is required although it will not be shown publicly.
All comments go through Akismet whose privacy policy can be found here:{' '}
<a href="https://akismet.com/privacy/">https://akismet.com/privacy/</a>.
I don't use your Gravatar but will publish whatever name you put in the
form and the date and, as you might expect, the comment. The data is
stored on the WordPress install on WP Engine. I have to manually approve
comments, they won't show up immediately.
</Styled.p>
<form
sx={{ variant: `forms.main` }}
onSubmit={e => {
e.preventDefault()
setCommentStatus('loading')
}}
>
<input type="hidden" name="botField" />
<div className="field">
<label htmlFor="userName">Name</label>
<input
type="text"
name="userName"
id="userName"
value={userName}
onChange={e => setUserName(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input
type="text"
name="email"
id="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="message">Message</label>
<textarea
name="message"
id="message"
rows="6"
value={message}
onChange={e => setMessage(e.target.value)}
/>
</div>
<ul className="actions">
<li>
<input
type="submit"
onClick={createComment}
value="Send Message"
className="special"
/>
</li>
<li>
<input
type="reset"
value="Clear"
onClick={e => {
setMessage('')
setCommentStatus(false)
setUserName('')
}}
/>
</li>
</ul>
</form>
</div>
)
switch (commentStatus) {
case 'success': // A successful submission.
return 'Your comment has been successfully submitted.'
case 'loading': // Just submitted, no response yet.
return 'Please wait. Your comment is being submitted.'
case 'error': // Something went wrong.
return 'There was an error in your submission. Please try again later.'
default: // No submission, render the form.
return renderCommentForm
}
}
export default CommentForm
Summing up
I’m happy with this tbh. I have a slight feeling of weirdness about combining the REST API with GraphQL but I’m ok with that. The entire point of Gatsby is to get data from different sources and using whatever is easiest for each particular case makes sense to me.
Leave a Reply