In the first part of this series we built the basics of a commenting system while in the second one we finally added nested comments. In this third and final article we'll add Emoji Reactions. This is going to be useful because people can interact with your content without the need to write a comment. An alternative would be a Reddit-like voting system, but I think that emojis will add a bit of color to our comments, so I decided to go for them.
Announcement: I made this part of the project a standalone library. You can now add Emoji Reactions everywhere easily and without impacting performance! Check it out here: lepre on npm.
Features
The emojis should update real-time and be lightweight, as we don't want to slow down everything just for a bunch of smiley faces. I tried various libraries, but they all were too heavy (we're talking megabytes) or slow. We need reactions for each comment and if the library isn't fast and efficient we can break the site quite easily. Because of that, I decided to create my own emoji picker, with some limitations of course:
- Limited selection of Emojis (which is a great thing tbh, I'm going to explain why soon)
- No skin color alternatives, everyone is a Simpson (again, great)
These limitations are actually useful because each emoji is rendered with it's own counter and displayed near the comment, and with 3,304 emojis currently existing it would become impossibile to render them all. Also, we can just choose to use themed emojis depending on the context. You want to use this in you cooking blog? Just add some cooking-related emojis to make your blog more fun.
Data Schema
We already created our data schema in the first article, so I'm just going to explain it's structure quickly.
commentId
is the id or key (they're usually different parameters, but in our case they're the same) of the comment, no matter if it's a parent or a child.
reactions
is an array containing all the Reactions relative to that comment. A Reaction is composed of:
-
emoji
, the emoji itself -
counter
of every time that emoji was clicked/selected -
label
, for accessibility reasons
Components
Let's start making some components, starting from the basic ones and adding something to them in each step. Create a new folder in the components
one to keep things tidy. I called mine simply Emoji
.
Emoji Component
A basic component which will render an emoji with the correct attributes for accessibility, role="img"
and aria-label
.
// components/Emoji/Emoji.js
export default function Emoji({ emoji, label, className, onClickCallback }) {
return (
<span
className={
className ? className + " emoji" : "emoji"
}
role="img"
aria-label={label ? label : ""}
aria-hidden={label ? "false" : "true"}
onClick={onClickCallback}
>
{emoji}
</span>
);
}
This component will simply render an emoji. The props emoji
and label
are those we'll get from Sanity, className
is an optional extra class, onClickCallback
is an optional callback for the onClick
event. Later on we'll do some basic styling, so this time I'm going to define classes too.
Emoji With Counter
An Emoji with a Counter showing how many times it was selected.
// components/Emoji/EmojiWithCounter.js
import Emoji from "./Emoji";
export default function EmojiWithCounter({emoji, emojiLabel, initialCounter, onIncrease}) {
return (
<span
className="emoji-container"
id={emojiLabel}
onClick={() => onIncrease(emoji)}
>
<Emoji emoji={emoji} label={emojiLabel} />
<div className="emoji-counter-div">
<span className="emoji-counter">{initialCounter}</span>
</div>
</span>
);
}
Pretty self-explanatory, this will render an Emoji with a counter on top of it. onIncrease
is a callback for the onClick
event.
Before continuing I feel the need to explain the difference between these two components, because there might be some confusion on why I had to pass and call two different callbacks both for the onClick
event.
The difference is quite simple. As you saw in the screenshot in the beginning of the article, there will be a box with "unselected" emojis, and a row of selected emojis with a counter on them (see the demo if this isn't clear). So, we'll use the Emoji
component for the unselected emojis. Its callback will create a new object in the database and start its counter at 1. Also, it will remove the emoji from the unselected box and move it to the row of selected ones.
EmojiWithCounter
is the component used to render the selected emojis.
Emoji Adder
This component will handle the opening and closing of the unselected emojis. We don't want to clutter the comments with emojis everywhere, so by default only the selected ones should be visible. Also, it renders the unselected emojis menu.
// components/Emoji/EmojiAdder.js
import Emoji from "./Emoji";
import { Fragment, useState } from "react";
import { nanoid } from 'nanoid'
export default function EmojiAdder({selectedEmojis, updateEmojiCount, EMOJI_OPTIONS}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
// We have an array of already selected emojis
const alreadySelectedEmojis = selectedEmojis.map(e => e.emoji);
// We create an array of Emoji components that are not already selected
const emojiOptions = EMOJI_OPTIONS.filter(
e => !alreadySelectedEmojis.includes(e.emoji)
).map(singleEmoji => (
<Emoji
key={nanoid()}
emoji={singleEmoji.emoji}
label={singleEmoji.label}
onClickCallback={() => {
updateEmojiCount(singleEmoji.emoji); // We pass a callback which will add the emoji to the selected ones on click
toggleMenu();
}}
/>
));
return (
<Fragment>
{emojiOptions.length > 0 && (
<span className="reaction-adder-emoji">
<Emoji
onClickCallback={toggleMenu}
emoji={"+"}
label="emoji-adder"
/>
<EmojiMenu />
</span>
)}
</Fragment>
);
function EmojiMenu() {
return (
<div
className={
isMenuOpen
? "emoji-adder-menu-open"
: "emoji-adder-menu-closed"
}
>
{emojiOptions}
</div>
);
}
}
We now have to stitch all of these components together, but before we do that we need something else.
Emoji Context
useContext is a React Hook that can provide something like a global state. Explaining it is out of the scope of this articles, if you want to know more the React Documentation is a good place to start.
We're going to create a Context to hold every reaction added to every comment. I decided to do this to reduce the calls to Sanity backend, because with this method we request everything at once when loading comments.
So, let's open the components/Comments/AllComments.js
file.
import { useState, useEffect, createContext } from "react";
[...]
const ReactionsContext = createContext(undefined);
export default function AllComments() {
const [reactions, setReactions] = useState();
[...]
useEffect(async () => {
[...]
client
.fetch(`*[_type == "commentReactions"]`)
.then(r => setReactions(r));
}
[...]
return (
<ReactionsContext.Provider value={reactions}>
<ul>{commentList}</ul>
</ReactionsContext.Provider>
);
}
With these additions we now can access the ReactionsContext
and the value of reactions
from everywhere in our application.
For the full code of this file see the repo.
Emoji Selection
As said in the beginning of this article, we need to define ourselves the available emojis.
Wherever you want, create a file to hold an array of emojis that you want to use in your reactions.
I created a lib
folder and inside a emojiConfig.js
file.
const DEFAULT_EMOJI_OPTIONS = [
{
emoji: "😄",
label: "happy",
},
{
emoji: "📚",
label: "books",
},
{
emoji: "😟",
label: "suprised",
},
{
emoji: "🐱",
label: "cat",
},
{
emoji: "🐼",
label: "panda",
},
];
export { DEFAULT_EMOJI_OPTIONS };
Now we can go back and finish our Reactions Block.
Full Reaction Block
Time to assemble everything!
First, import everything we need and create some global variables that we'll need later on.
import EmojiWithCounter from "./EmojiWithCounter";
import EmojiAdder from "./EmojiAdder";
import { ReactionsContext } from "../Comments/AllComments";
import { DEFAULT_EMOJI_OPTIONS } from "../../lib/emojiConfig";
import {nanoid} from "nanoid";
import { useState, useEffect, useContext } from "react";
import { client } from "../../lib/sanityClient";
let dbDebouncerTimer;
let querySub;
Now prepare the state.
export default function ReactionBlock({ commentId }) {
// We get the initial reactions we previously fetched from the Context
// and filter them so we only have the ones for this comment.
// Also, I wanted to sort them by their amount.
const contextReactions = useContext(ReactionsContext)
?.filter(r => r.commentId === commentId)
.map(r => r.reactions)
?.sort((a, b) => (a.counter < b.counter ? 1 : -1))[0];
const [reactions, setReactions] = useState([]);
const [shouldUpdateDb, setShouldUpdateDb] = useState(false);
Now we use the useEffect
hook to subscribe to the query and get real-time updates.
useEffect(() => {
// If there are reactions in the context, set them
if (contextReactions) setReactions(contextReactions);
// Subscribe to the query Observable and update the state on each update
const query = `*[_type == "commentReactions" && commentId=="${commentId}"]`;
querySub = client.listen(query).subscribe(update => {
if (update) {
setReactions([
...update.result.reactions.sort((a, b) =>
a.counter < b.counter ? 1 : -1
),
]);
}
});
// Unsubscribe on Component unmount
return () => {
querySub.unsubscribe();
};
}, []);
Now we need a function to update the database whenever we click an emoji.
const updateEmojiCount = emoji => {
setShouldUpdateDb(false);
let emojiFromState = reactions.filter(em => em.emoji === emoji)[0];
// If the selected emoji wasn't in the state, it's a new one
if (!emojiFromState) {
emojiFromState = DEFAULT_EMOJI_OPTIONS.filter(
em => em.emoji === emoji
)[0];
emojiFromState.counter = 1;
setReactions(reactions =>
[...reactions, emojiFromState].sort((a, b) =>
a.counter < b.counter ? 1 : -1
)
);
} else {
emojiFromState.counter++;
setReactions(reactions =>
[
...reactions.filter(
rea => rea.emoji !== emojiFromState.emoji
),
emojiFromState,
].sort((a, b) => (a.counter < b.counter ? 1 : -1))
);
}
setShouldUpdateDb(true);
};
This function toggles the shouldUpdateDb
state and we can listen to that change to call another function.
useEffect(() => {
if (shouldUpdateDb) updateReactionsOnDatabase();
setShouldUpdateDb(false);
}, [shouldUpdateDb]);
function updateReactionsOnDatabase() {
clearTimeout(dbDebouncerTimer);
dbDebouncerTimer = setTimeout(() => {
fetch("/api/addReaction", {
method: "POST",
body: JSON.stringify({
commentId: commentId,
reactions: reactions,
}),
});
dbDebouncerTimer = null;
}, 1000 * 1);
}
All of this is needed to debounce the database update. Our Reactions Block will now update the database one second after the last click, meaning that 10 click won't perform 10 database updates.
Finally, we map the reactions and render everything.
const mappedReactions = reactions.map(reaction => (
<EmojiWithCounter
key={nanoid()}
emoji={reaction.emoji}
emojiLabel={reaction}
initialCounter={reaction.counter}
onIncrease={updateEmojiCount}
/>
));
return (
<div className="reaction-block">
{mappedReactions}
<EmojiAdder
selectedEmojis={reactions}
updateEmojiCount={updateEmojiCount}
EMOJI_OPTIONS={DEFAULT_EMOJI_OPTIONS}
/>
</div>
);
The full code (not in the same order) is as follows:
import EmojiWithCounter from "./EmojiWithCounter";
import {nanoid} from "nanoid";
import EmojiAdder from "./EmojiAdder";
import { useState, useEffect, useContext } from "react";
import { ReactionsContext } from "../Comments/AllComments";
import { client } from "../../lib/sanityClient";
import { DEFAULT_EMOJI_OPTIONS } from "../../lib/emojiConfig";
let dbDebouncerTimer;
export default function ReactionBlock({ commentId }) {
// We get the initial reactions we previously fetched from the Context
const contextReactions = useContext(ReactionsContext)
?.filter(r => r.commentId === commentId)
.map(r => r.reactions)
?.sort((a, b) => (a.counter < b.counter ? 1 : -1))[0];
const [reactions, setReactions] = useState([]);
const [shouldUpdateDb, setShouldUpdateDb] = useState(false);
let querySub = undefined;
useEffect(() => {
// If there are reactions in the context, set them
if (contextReactions) setReactions(contextReactions);
// Subscribe to the query Observable and update the state on each update
const query = `*[_type == "commentReactions" && commentId=="${commentId}"]`;
querySub = client.listen(query).subscribe(update => {
if (update) {
setReactions([
...update.result.reactions.sort((a, b) =>
a.counter < b.counter ? 1 : -1
),
]);
}
});
// Unsubscribe on Component unmount
return () => {
querySub.unsubscribe();
};
}, []);
useEffect(() => {
if (shouldUpdateDb) updateReactionsOnDatabase();
setShouldUpdateDb(false);
}, [shouldUpdateDb]);
// Onclick, update the emoji counter and start a timer to update the database
const updateEmojiCount = emoji => {
setShouldUpdateDb(false);
let emojiFromState = reactions.filter(em => em.emoji === emoji)[0];
if (!emojiFromState) {
emojiFromState = DEFAULT_EMOJI_OPTIONS.filter(
em => em.emoji === emoji
)[0];
emojiFromState.counter = 1;
setReactions(reactions =>
[...reactions, emojiFromState].sort((a, b) =>
a.counter < b.counter ? 1 : -1
)
);
} else {
emojiFromState.counter++;
setReactions(reactions =>
[
...reactions.filter(
rea => rea.emoji !== emojiFromState.emoji
),
emojiFromState,
].sort((a, b) => (a.counter < b.counter ? 1 : -1))
);
}
setShouldUpdateDb(true);
};
// Debouncer to avoid updating the database on every click
function updateReactionsOnDatabase() {
clearTimeout(dbDebouncerTimer);
dbDebouncerTimer = setTimeout(() => {
fetch("/api/addReaction", {
method: "POST",
body: JSON.stringify({
commentId: commentId,
reactions: reactions,
}),
});
dbDebouncerTimer = null;
}, 1000 * 1);
}
const mappedReactions = reactions.map(reaction => (
<EmojiWithCounter
key={nanoid()}
emoji={reaction.emoji}
emojiLabel={reaction}
initialCounter={reaction.counter}
onIncrease={updateEmojiCount}
/>
));
return (
<div className="reaction-block">
{mappedReactions}
<EmojiAdder
selectedEmojis={reactions}
updateEmojiCount={updateEmojiCount}
EMOJI_OPTIONS={DEFAULT_EMOJI_OPTIONS}
/>
</div>
);
}
Backend
Last but not least, we need a serverless function to update our database. This is way easier than the comment creation function.
// pages/api/addReaction.js
import { writeClient } from "../../lib/sanityClient";
export default (req, res) => {
return new Promise(resolve => {
const body = JSON.parse(req.body);
const _id = body.commentId;
const reactions = body.reactions;
reactions.forEach(r => (r._key = r.label));
const query = `*[_type == "commentReactions" && commentId == "${_id}"]{_id}[0]`;
writeClient.fetch(query).then(comment => {
if (comment) {
writeClient
.patch(comment._id)
.set({ reactions: reactions })
.commit()
.then(() => {
resolve(res.status(200).end());
});
} else {
writeClient
.create({
_type: "commentReactions",
commentId: _id,
reactions: reactions,
})
.then(() => {
resolve(res.status(200).end());
});
}
});
});
};
Styling
As promised, here's some basic styling:
.emoji {
margin: 10px;
font-size: 25px;
display: flex;
align-items: center;
cursor: pointer;
vertical-align: middle;
transform: translateZ(0);
box-shadow: 0 0 1px rgba(0, 0, 0, 0);
backface-visibility: hidden;
-moz-osx-font-smoothing: grayscale;
transition-duration: 0.1s;
transition-property: transform;
}
.reaction-div {
margin-top: 5px;
display: inline-flex;
flex-flow: wrap;
}
.emoji-container {
position: relative;
user-select: none;
display: flex;
}
.emoji-counter-div {
position: absolute;
top: -2px;
right: 3px;
z-index: -5;
}
.emoji-counter {
font-weight: bold;
padding: 2px 5px;
border-radius: 30%;
background-color: #f55742;
color: #fefefe;
}
.emoji:hover,
emoji:focus,
emoji:active {
transform: scale(1.1);
}
.comment-info {
margin: auto 0px;
}
.comment-info-container {
height: 40px;
display: flex;
}
.reaction-block {
display: inline-flex;
flex-flow: wrap;
}
.reaction-adder-emoji {
user-select: none;
position: relative;
display: inline-block;
}
.emoji-adder-menu-open {
position: absolute;
display: flex;
top: 0px;
left: 35px;
border-radius: 10px;
box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px;
background-color: #fefefe;
flex-wrap: wrap;
z-index: 10;
width: 400%;
}
.emoji-adder-menu-closed {
display: none;
}
Conclusion
This series is now finished. I hope it was useful for someone and that everything was -mostly- clear.
If you have any doubts you can comment here or write me on my social media.
Full Series:
Top comments (0)