DEV Community

Cover image for Creating Mentions And Hashtags In ReactJS
Gaurav Adhikari
Gaurav Adhikari

Posted on • Edited on • Originally published at gauravadhikari.com

Creating Mentions And Hashtags In ReactJS

# And @ In React Apps

There are many use cases in real-world applications where you need to implement triggers like showing a list of users to mention on pressing @ symbol or to write a tag after pressing the # key, which should actually save to DB as tag/mention and then render it correctly to the screen.

It is understandable for a social media app to have such a feature, but this can be taken further to some apps where you need to trigger functions on some special keypresses, like in a Library Management Software to tag a Resource in someplace.

If you are interested in the preview of the app, here is the deployed URL -> https://hashtags-n-mentions.netlify.app
Please note: Since I am using free API backend, it will show up posts/users after a while, please refresh after 10 seconds.

Prerequisites

— Node.js ≥v6 is installed on your machine
— npm/yarn is installed on your machine
— You have a basic understanding of React.js

We Will Be Using

— Create-React-App template.
— Functional components with hooks.
— TailwindCSS to style our app.
— NPM package called react-mentions
— Backend API to fetch posts, users, tags, and create posts. (No worries, I have already created the API)

Now let’s get our hands dirty?
Setup our app using CRA template

Alt Text

Once it is done, move in the directory and start the show!

Alt Text

We will create the UI first then implement functionality ;-)
This is the folder structure of our final application

Alt Text

Before anything else, we will install TailwindCSS and configure it in our app
You can refer to their doc - https://tailwindcss.com/docs/guides/create-react-app or 
 ~ Take the boilerplate code till this point from here https://github.com/gauravadhikari1997/hashtags-and-mentions-in-react/tree/98737fc89586d6697f23349f1e0c98fa7ac38cfb

yarn add react-router-dom axios react-mentions html-react-parser
Enter fullscreen mode Exit fullscreen mode

App.js

import { BrowserRouter, Route } from "react-router-dom";

import { Filter, Header, NewPost, Posts } from "./components";

function App() {
  return (
    <BrowserRouter>
      <Header />
      <Route exact path="/">
        <section className="px-4 sm:px-6 lg:px-4 xl:px-6 pt-4 pb-4 sm:pb-6 lg:pb-4 xl:pb-6 space-y-4">
          <Filter />
          <Posts />
        </section>
      </Route>
      <Route path="/new">
        <NewPost />
      </Route>
    </BrowserRouter>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

components/Header.js

import { Link } from "react-router-dom";

const Header = () => {
  return (
    <header className="flex items-center justify-between">
      <Link to="/">
        <h2 className="text-lg leading-6 font-medium text-black px-4 py-2">
          <span className="text-green-400">#</span>n
          <span className="text-blue-400">@</span>
        </h2>
      </Link>
      <Link
        to="/new"
        className="hover:bg-light-blue-200 hover:text-light-blue-800 group flex items-center rounded-md bg-light-blue-100 text-light-blue-600 text-sm font-medium px-4 py-2"
      >
        New
      </Link>
    </header>
  );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

components/Filter.js

const Filter = () => {
  return (
    <form className="relative">
      <svg
        width="20"
        height="20"
        fill="currentColor"
        className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
      >
        <path
          fillRule="evenodd"
          clipRule="evenodd"
          d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
        />
      </svg>
      <input
        className="focus:ring-1 focus:ring-light-blue-500 focus:outline-none w-full text-sm text-black placeholder-gray-500 border border-gray-200 rounded-md py-2 pl-10"
        type="text"
        aria-label="Filter posts"
        placeholder="Filter posts"
      />
    </form>
  );
};

export default Filter;
Enter fullscreen mode Exit fullscreen mode

services/service.js

import axios from "axios";

const instance = axios.create({
  baseURL:
    process.env.REACT_APP_SERVER_API ||
    "https://hashtags-n-mentions.herokuapp.com/api",
  headers: { "Content-Type": "application/json" },
  timeout: 1000 * 2, // Wait for request to complete in 2 seconds
});

export default instance;

Here we have created an instance from axios so that next time we do not have to pass baseURL and headers in every request.
services/index.js

export { default as APIservice } from "./service";
Enter fullscreen mode Exit fullscreen mode

components/Posts.js

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { APIservice } from "../services";

import Card from "./Card";
const Posts = () => {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    getPosts();
  }, []);

  async function getPosts() {
    try {
      const res = await APIservice.get("/posts");
      setPosts(res.data.posts);
    } catch (error) {
      console.error(error);
    }
  }

  return (
    <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4">
      {posts && posts.length > 0
        ? posts
            .sort((a, b) => b.createdAt - a.createdAt)
            .map((post) => (
              <Card key={post._id} title={post.title} content={post.content} />
            ))
        : null}
      <li className="hover:shadow-lg flex rounded-lg">
        <Link
          to="/new"
          className="hover:border-transparent hover:shadow-xs w-full flex items-center justify-center rounded-lg border-2 border-dashed border-gray-200 text-sm font-medium py-4"
        >
          New Post
        </Link>
      </li>
    </ul>
  );
};

export default Posts;
Enter fullscreen mode Exit fullscreen mode

Here we are getting the posts from the server in useEffect, and we are populating that data to our state posts using setPosts.

Later in the return statement we are checking if there are posts and then sorting posts based on created time.

Finally the posts are rendered in the Card component which takes title and content as props.

Card.js

import parse from "html-react-parser";
import { Link } from "react-router-dom";

const Card = ({ title, content }) => {
  return (
    <li x-for="item in items">
      <div
        href="item.url"
        className="hover:bg-light-blue-500 hover:border-transparent hover:shadow-lg group block rounded-lg p-4 border border-gray-200"
      >
        <div className="grid sm:block lg:grid xl:block grid-cols-2 grid-rows-1 items-center">
          <div>
            <span className="leading-6 font-medium text-black">{title}</span>
          </div>
          <div>
            <span className="group-hover:text-light-blue-200 text-gray-500 text-sm font-medium sm:mb-4 lg:mb-0 xl:mb-4">
              {parse(content, {
                replace: (domNode) => {
                  if (domNode.name === "a") {
                    const node = domNode.children[0];
                    return (
                      <Link
                        to={domNode.attribs.href}
                        className={
                          node.data[0] === "#"
                            ? "text-green-400"
                            : "text-blue-400"
                        }
                      >
                        {node.data}
                      </Link>
                    );
                  }
                },
              })}
            </span>
          </div>
        </div>
      </div>
    </li>
  );
};

export default Card;
Enter fullscreen mode Exit fullscreen mode

Important thing to note in this component is the parse which we have imported from html-react-parser. We are parsing our content so that if we get an anchor tag(a href), we replace it with Link (from react-router-dom) else the anchor tag will refresh the whole page on click.

By the way, these anchor tags(now Link) are the hashtags or mentions, now you can create dynamic routes for /tags/:tag_name or /user/:user_id to show relevant data.

/index.css

/* ./src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

.mentions {
  margin: 1em 0;
}

.mentions--singleLine .mentions__control {
  display: inline-block;
}
.mentions--singleLine .mentions__higlighter {
  padding: 1px;
  border: 2px inset transparent;
}
.mentions--singleLine .mentions__input {
  padding: 5px;
  border: 2px inset;
}

.mentions--multiLine .mentions__control {
  font-family: monospace;
  font-size: 11pt;
  border: 1px solid silver;
}
.mentions--multiLine .mentions__highlighter {
  padding: 9px;
}
.mentions--multiLine .mentions__input {
  padding: 9px;
  min-height: 63px;
  outline: 0;
  border: 0;
}

.mentions__suggestions__list {
  background-color: white;
  border: 1px solid rgba(0, 0, 0, 0.15);
  font-size: 10pt;
}

.mentions__suggestions__item {
  padding: 5px 15px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.15);
}

.mentions__suggestions__item--focused {
  background-color: #cee4e5;
}

.mentions__mention {
  background-color: #cee4e5;
}
Enter fullscreen mode Exit fullscreen mode

/components/NewPost.js

import { useEffect, useState, useRef } from "react";
import { MentionsInput, Mention } from "react-mentions";
import { Link, useHistory } from "react-router-dom";

import { APIservice } from "../services";

const NewPost = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [users, setUsers] = useState([]);
  const [tagNames, setTagNames] = useState([]);
  const myInput = useRef();
  const history = useHistory();

  useEffect(() => {
    getActors();
  }, []);

  function addContent(input) {
    if (input.length <= 350) {
      setContent(input);
    }
  }

  async function getActors() {
    const res = await APIservice.get(`/users`);
    // Transform the users to what react-mentions expects
    const usersArr = [];
    res.data.users.map((item) =>
      usersArr.push({
        id: item._id,
        display: item.name,
      })
    );
    setUsers(usersArr);
  }

  async function asyncTags(query, callback) {
    if (?query) return;

    APIservice.get(`/tag/search?name=${query}`)
      .then((res) => {
        if (res.data.tags.length) {
          const suggestion = { id: query, display: query };
          const tagsArray = res.data.tags.map((tag) => ({
            id: tag._id,
            display: tag.name,
          }));
          return [...tagsArray, suggestion];
        } else {
          return [{ id: query, display: query }];
        }
      })
      .then(callback);
  }

  async function savePost(e) {
    e.preventDefault();

    let newContent = content;

    newContent = newContent.split("@@@__").join('<a href="/user/');
    newContent = newContent.split("^^^__").join(`">@`);
    newContent = newContent.split("@@@^^^").join("</a>");

    newContent = newContent.split("$$$__").join('<a href="/tag/');
    newContent = newContent.split("~~~__").join(`">#`);
    newContent = newContent.split("$$$~~~").join("</a>");
    if (newContent !== "") {
      let body = newContent.trim();
      //Call to your DataBase like backendModule.savePost(body,  along_with_other_params);
      tagNames.map(async (tag) => {
        try {
          await APIservice.post("/tag", {
            name: tag,
          });
        } catch (error) {
          console.log(error);
        }
      });
      console.log(body);
      try {
        await APIservice.post("/post", {
          title,
          content: body,
          createdAt: new Date().getTime(),
        });
        history.push("/");
      } catch (error) {
        console.error(error);
      }
    }
  }

  return (
    <>
      <div className="heading text-center font-bold text-2xl m-5 text-gray-800">
        New Post
      </div>
      <form
        onSubmit={savePost}
        className="editor mx-auto w-10/12 flex flex-col text-gray-800 border border-gray-300 p-4 shadow-lg max-w-2xl"
      >
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="title border border-gray-300 p-2 mb-4 outline-none"
          spellCheck="false"
          placeholder="Title"
          type="text"
        />
        <div className="description outline-none">
          <MentionsInput
            className="mentions"
            inputRef={myInput}
            spellCheck="false"
            placeholder="Describe everything about this post here"
            value={content}
            onChange={(event) => addContent(event.target.value)}
          >
            <Mention
              trigger="@"
              data={users}
              markup="@@@____id__^^^____display__@@@^^^"
              style=`{{
                backgroundColor: "#daf4fa",
              }}`
              // onAdd={(id) => setActorIds((actorIds) => [...actorIds, id])}
              appendSpaceOnAdd={true}
            />
            <Mention
              trigger="#"
              data={asyncTags}
              markup="$$$____id__~~~____display__$$$~~~"
              style=`{{
                backgroundColor: "#daf4fa",
              }}`
              onAdd={(display) =>
                setTagNames((tagNames) => [...tagNames, display])
              }
              appendSpaceOnAdd={true}
            />
          </MentionsInput>
        </div>

        <div className="icons flex text-gray-500 m-2">
          <div
            onClick={() => {
              myInput.current.focus();
              setContent((content) => content + "@");
            }}
            className="mr-2 cursor-pointer hover:text-gray-700 border rounded-full py-1 px-6"
          >
            @
          </div>
          <div
            onClick={() => {
              myInput.current.focus();
              setContent((content) => content + "#");
            }}
            className="mr-2 cursor-pointer hover:text-gray-700 border rounded-full py-1 px-6"
          >
            #
          </div>
          <div className="count ml-auto text-gray-400 text-xs font-semibold">
            {350 - content.length}/350
          </div>
        </div>
        <div className="buttons flex">
          <Link
            to="/"
            className="btn border border-gray-300 p-1 px-4 font-semibold cursor-pointer text-gray-500 ml-auto"
          >
            Cancel
          </Link>
          <button className="btn border border-indigo-500 p-1 px-4 font-semibold cursor-pointer text-gray-200 ml-2 bg-indigo-500">
            Post
          </button>
        </div>
      </form>
    </>
  );
};

export default NewPost;
Enter fullscreen mode Exit fullscreen mode

Note: Please remove single backticks from style tags from both Mention, I had to put it as I was getting error(liquid) in publishing the post to dev.to, Sorry for inconvenience.

Pretty big component ha?
Actually, this is the component that is the essence for this article so bear with me some more time ;-)
Here we have states for title and content of post which are self explanatory.
Users and tagNames are the data which we will get from backend and render on @ and # trigger respectively.

There are two ways in which we can show data to user in React Mentions input
Load data initially (like we did for users ie in useEffect)
Load data asynchronously (asyncTags function which will get executed every time tag input changes)

Now have a look at MentionsInput in return statement
Alt Text

The first thing to note is that MentionsInput is a textarea, so we have given value and set onChange for content to it.
The second thing is there are two Mention components inside it which are nothing but the triggers for @ and # respectively.

For every Mention, there are two required things ie trigger(like @ # $..) and data(either static or async) and we are good to go.

Saving Post Data To DB

Alt Text

Before saving the data to DB, we will have to handle it so that we can render it correctly later. After extracting the mentions and tags from the content, we save it to DB.
Also, we have called add/tag API so that new tags added by users are saved to DBtoo.

At the last of code, we have two buttons for adding @ or # by clicking the UI(like linkedin), we have just made a ref of content input, and call
— myInput.current.focus() to focus cursor to content input box
— setContent((content) => content + "@") to append @/# after whatever the state of content is.

Github repo link for the above app https://github.com/gauravadhikari1997/hashtags-and-mentions-in-react

Thanks for reading. Hope you like the article and find it useful.
Buy Me A Coffee

Top comments (1)

Collapse
 
thomascolechin profile image
ThomasColechin • Edited

Mentions and hashtags are two important ways to engage your audience and make them feel part of the conversation. Say you want to talk about a new business idea that you've come up with and you want to gauge interest in it, and if you interest in coffee you can check coffee hashtags for tiktok that rank your videos easily. You might create a hashtag that includes both "newbusiness" and "idea" so people can find it easily.