DEV Community

Alessio Franceschi
Alessio Franceschi

Posted on • Updated on • Originally published at alessiofranceschi.me

Emoji Reactions for Comments - Building a Real-Time Commenting System in React [Part 3/3]

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.

Example

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

The Emoji Reaction Block

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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.

The Different 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>
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    };
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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());
                    });
            }
        });
    });
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 repo: GitHub.
Demo here.

Full Series:

Discussion (0)