Mentions / Hashtags / ReactJS

Creating Mentions And Hashtags In ReactJS

December 01, 20229 min read
Creating Mentions And Hashtags In ReactJS
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.

Want to give it a try ? Here is deployed version https://hashtags-n-mentions.netlify.app

Prerequisites

Node.js ≥v6 is installed on your machine

npm/yarn is installed on your machine

— You have a basic understanding of React.js

What 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

create react app

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

yarn start

We will create the UI first then implement functionality ;-)

This is the folder structure of our final application

react app folder structure

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

Let’s install all packages we are going to use in one go

yarn add react-router-dom axios react-mentions html-react-parser

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;

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>
  );
};

components/Filter.js

export default Header;
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;

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";

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;

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;

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;
}

/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;

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

React Mentions Input Component

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

save react-mentions to db

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 DB too.

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 ☕

Creating Mentions In ReactJSCreating Hashtags In ReactJS