DEV Community

Cover image for Recreating the Dev Reaction Element using NextJS and Spring Boot
Vinit Gupta
Vinit Gupta

Posted on

Recreating the Dev Reaction Element using NextJS and Spring Boot

I have always been fascinated by the reactions functionality (both UI and how it works) of this platform 🤯.

As a developer, I could not hold this curiosity for long and replicated this feature over the week.

reactions

This might look really simple, but there are a lot of steps involved. So as always let's dive into them 1 by 1.

0️⃣ Prerequisites

I am building this UI and functionality using

  • NextJS for frontend
  • TailwindCSS for Styling
  • Tanstack Query for query caching and optimizations
  • Spring Boot backend for API request handling
  • MongoDB to store and update reactions

You are free to choose what tech stack you want as the steps are not bound to any particular technology involved.

1️⃣ Building the UI

Starting with the simplest step, let's build the actual reaction button along with the different reaction emojis.

First to define the types that we are going to use :

Reaction Keywords and Emojis

The reactions will have a fixed set of values and the associated emojis. 2 type definitions are involved in this :


// reactions enum
export type ReviewReactionStringType = "helpful" | "insightful" | "funny" | "agree" | "love";


// emoji mapping
export const REVIEW_REACTIONS: ReviewReactionType[] = [
    { type: "helpful", emoji: "👍", label: "Helpful" },
    { type: "insightful", emoji: "🧠", label: "Insightful" },
    { type: "funny", emoji: "😂", label: "Funny" },
    { type: "agree", emoji: "🤝", label: "Agree" },
    { type: "love", emoji: "❤️", label: "Loved It" },
  ];


Enter fullscreen mode Exit fullscreen mode

Once the above are defined, we can move forward to building the UI.

The Reactions Component

The reactions component takes in a few initialization data :

  • Reactions mapping defined above.
  • The reaction event handler.
  • User reactions made now or previously.
  • The total reactions map containing data for each reaction.

The Reaction List uses the user reactions data to show a visual representation that for the post, what reactions have been added by the user.

This is done keeping a map of the active reactions in the following format :

{
   love : true,
   insightful : false,
...
}
Enter fullscreen mode Exit fullscreen mode

And this map is updated on every reaction click by the user(along with an API call using the event handler passed).

 const [activeReactions, setActiveReactions] = useState(
  Object.fromEntries(
    Object.keys(initialReactions).map((reaction) => [
      reaction as ReviewReactionStringType,
      initialUserReactions.includes(reaction as ReviewReactionStringType),
    ])
  )
);

  const handleClick = (reactionType: ReviewReactionStringType) => {
    // Immediately toggle the reaction in our local state
    setActiveReactions(prev => {
        return {
            ...prev,
            [reactionType] : !prev[reactionType]
        }
    });


    // Call the API
    onReactionClick(reactionType);
  };
Enter fullscreen mode Exit fullscreen mode

The reaction count for each reaction is also displayed accordingly.

<ul className="menu menu-sm dropdown-content bg-base-200 top-8 left-6 rounded-box z-[1] mt-3 min-w-min p-2 flex-row flex-nowrap border border-slate-500 border-opacity-40 gap-6 text-3xl">
      {reactions.map((reaction: ReviewReactionType, index: number) => (
        <li 
          key={`${reaction.type}-${activeReactions[reaction.type]}`}
          onClick={() => handleClick(reaction.type)}
          className={`flex flex-col items-center justify-center
            px-2 py-0 rounded-md border 
            ${activeReactions[reaction.type]
              ? 'border-slate-300' 
              : 'border-base-200'} 
            hover:border-slate-300
            transition-all duration-200
          `}
        >
          {reaction.emoji}
          <span className={`text-sm`}>{reactionsMap[reaction.type]}</span>
        </li>
      ))}
    </ul>
Enter fullscreen mode Exit fullscreen mode

The Custom Reaction Handler hook

The reaction handler function calls the following custom hook that uses React Query to call the API endpoint to toggling the reaction.

Note 💡 : One important design decision is to not keep a local state of whether the API call is for setting or un-setting a particular reaction. It is handled by the API.

Keeping the above in mind, what the reaction handler does is pass :

  1. the entityId which is used to identify the Post on which the reaction is made,
  2. the reaction made, and
  3. the entityType which can be a Post or Comment(to be used in future).

Once the current request is completed, the reactions fetched for the post needed to be refetched. React Query provides a really useful method for handling this : QueryClient.invalidateQueries that takes in the Query Key for the post reactions(reaction-id) and refetches the data. This updates the total counts of the reactions for the post as well.

const useToggleReaction = ({
    entityId,
}:{
    entityId : string,
}) => {
    const queryClient = useQueryClient();
    const {sessionToken} = useGlobalStore();
    return useMutation({
        mutationKey: [`toggle-reaction-${entityId}`],
        mutationFn: async ({
            reactionType = "love",
            entityType = EntityType.POST
        }: {
                reactionType : ReviewReactionStringType,
                entityType : EntityType
            }) => {

            const token = sessionToken || queryClient.getQueryData(['access-token']);


            const toggleReactionData : ReactionData = {
                entityType,
                entityId,
                reactionType
            };

            ReactionSchema.parse(toggleReactionData);


            const headers = {
                "Authorization": `Basic ${token?.trim()}`,    
            }

            try {
            const toggleReactionResponse : AxiosResponse = await privateAccessClient.post('/reaction',toggleReactionData, {
                headers : headers    
            });

            const toggleReactionResponseData = toggleReactionResponse.data;

            queryClient.invalidateQueries({
                queryKey : ["reaction-"+entityId]
            })

            const toggleReactionResponseMessage : string = toggleReactionResponseData as string || '';

            return {message : toggleReactionResponseMessage, type : ResponseType.success};
        } catch (error) {
            // handle error
        }

        },
    })
}
Enter fullscreen mode Exit fullscreen mode

The frontend is ready, but wait...

Frontend is ready before backend meme

2️⃣ Reaction data design

After the view is done, we need to create the Controllers for reactions, which includes 2 endpoints : fetching the post reactions and toggling the user's reactions for that post.

But to be able to create these endpoints, we first need to create the data design for :

  • Reaction entity that will be stored in MongoDB ⛃.
  • Reaction toggle Request DTO.
  • Reaction fetch Response DTO.

Reaction Data Entity

To store the reactions in the Database, a reaction entity is used for every entity.
This works in a way like, for each entity (in these case, a post) has the following data in the reaction document :

  1. The entityId and entityType for the current reaction document. In this case, entityId refers to the post ID and entity type is POST.
  2. The Reactions map which stores the reactions for that post for all the users, with the User ID as the key and all reactions for that particular user in a set.
  3. The Reactions count map which is used to store the total of each type of reaction for all the users.

📌 Since every time we fetch the reactions, it will be fetched using the entity type and entity ID, a compound index can be created using the @CompoundIndex annotation.

@Document(collection = "reaction")
@Data
@Builder
@CompoundIndex(name = "entity_idx", def = "{'entityId': 1, 'entityType': 1}")
public class Reaction {
    @Id
    private String id;

    private String entityId; // ID of the post, comment or any other entity
    private EntityType entityType; // type of the entity

    private HashMap<String, Set<ReactionType>> reactions;

    private Date lastModifiedOn;

    private HashMap<ReactionType, Integer> reactionCount;

}
Enter fullscreen mode Exit fullscreen mode

Reaction Request and Response DTO

The reaction request requires 3 pieces of data (as the toggling is handled by the reaction service):

  • The Reaction type,
  • The Entity ID, and
  • The Entity Type (POST/COMMENT)
public record 
ReactionRequestDTO(
        String entityId,
        EntityType entityType,
        ReactionType reactionType) {
}
Enter fullscreen mode Exit fullscreen mode

The reaction response is to have :

  • The Current User's reactions on the entity
  • Total reactions count
  • Reactions Map used to store the total of each type of reaction for all the users
public record ReactionsDTO(
        Set<ReactionType> userReactions, 
        long totalReactions, 
        Map<ReactionType, Integer> reactionsMap) {
}
Enter fullscreen mode Exit fullscreen mode

Reaction repository DB Methods

Fetching the user's reactions along with the counts of total reactions for the entity, which can be done using MongoDB aggregations.

This aggregation:

  1. Uses MongoDB's find operation with projection
  2. The value parameter specifies the filter criteria (like WHERE clause in SQL)
  3. The fields parameter specifies which fields to include:
    • 1 means include the field
    • The syntax 'reactions': { ?2: 1 } means "only include the entry in reactions map where the key matches the userId (third parameter)"
 @Query(value = "{ 'entityId': ?0, 'entityType': ?1 }",
            fields = "{ " +
                    "'id': 1, " +
                    "'entityId': 1, " +
                    "'entityType': 1, " +
                    "'reactions': { ?2: 1 }, " +
                    "'lastModifiedOn': 1, " +
                    "'reactionCount': 1 }")
    Optional<Reaction> findByEntityIdAndEntityTypeAggregated(
String entityId,                                                                     EntityType entityType,                                                                    String userId);
Enter fullscreen mode Exit fullscreen mode

Once the data designs are ready, let's work on the real action.

developing time

3️⃣ Reaction Service

The service layer is where all the logical stuff goes on.
First up, is the fetching of reactions for the entity.

Fetch reactions for the entity

For a particular entity, the entity ID, the entity type and the user ID of the current logged in user is required.

The reaction service fetches the details according to the above data as required in the ReactionsDTO defined above.

public ReactionsDTO getReactions(EntityType entityType, String entityId, String userEmail){
        String userId = reactionUtil.encodeKey(userEmail);

        Reaction userReaction = reactionRepository.findByEntityIdAndEntityTypeAggregated(entityId,entityType,userId)
                .orElse(Reaction.builder()
                        .reactions(new HashMap<>())
                        .entityType(entityType)
                        .entityId(entityId)
                        .reactionCount(reactionUtil.initReactionsCount())
                        .lastModifiedOn(new Date())
                        .build());

        long totalReactionsCount = 0;
        for(Map.Entry<ReactionType, Integer> reactionEntry : userReaction.getReactionCount().entrySet()){
            totalReactionsCount += reactionEntry.getValue();
        }

        return new ReactionsDTO(
                userReaction.getReactions().getOrDefault(userId, new HashSet<>()),
                totalReactionsCount,
                userReaction.getReactionCount());
    }

Enter fullscreen mode Exit fullscreen mode

This is relatively simple, compared to the toggle reaction service method below.

complex stuff

Toggling reactions

The toggleReaction method dynamically adds or removes a reaction while keeping storage efficient and counts accurate. While this seems complex, but breaking down into simpler steps help.

  1. Retrieve or Initialize Reaction – Fetches the reaction entry; if absent, creates a new one with default values.

  2. Toggle User Reaction – Adds the reaction if absent (toggle = 1), else removes it (toggle = -1).

  3. Update Storage – Removes empty reaction sets to optimize memory.

  4. Adjust Reaction Count – Updates the count, ensuring it doesn’t go below zero.

  5. Save & Return – Updates the timestamp, saves to DB, and returns true. 🚀

The code for the above steps is as below :

public boolean toggleReaction(ReactionRequestDTO reactionRequestDTO, String userId){
        Optional<Reaction> optionalReaction = reactionRepository
                .findByEntityIdAndEntityType(reactionRequestDTO.entityId(),
                        reactionRequestDTO.entityType());
        // counter to update total reactions count for each type
        int toggle = 0;

        Reaction reaction = optionalReaction.orElseGet(() ->
                Reaction.builder()
                        .reactionCount(reactionUtil.initReactionsCount())
                        .entityId(reactionRequestDTO.entityId())
                        .entityType(reactionRequestDTO.entityType())
                        .reactions(new HashMap<>())
                        .lastModifiedOn(new Date())
                        .build());

        Set<ReactionType> reactionsSet = reaction.getReactions().getOrDefault(reactionUtil.encodeKey(userId), new HashSet<>());

        if(reactionsSet.contains(reactionRequestDTO.reactionType())){
            toggle = -1; // remove
            reactionsSet.remove(reactionRequestDTO.reactionType());
        }
        else {
            toggle = 1; // add
            reactionsSet.add(reactionRequestDTO.reactionType());
        }


        if (reactionsSet.isEmpty()) {
            reaction.getReactions().remove(reactionUtil.encodeKey(userId));
        } else {
            reaction.getReactions().put(reactionUtil.encodeKey(userId), reactionsSet);
        }

        HashMap<ReactionType, Integer> reactionsCountMap = reaction.getReactionCount();

        reactionsCountMap.put(reactionRequestDTO.reactionType(),
                Math.max(0, reactionsCountMap.getOrDefault(
                        reactionRequestDTO.reactionType(), 0) + toggle)); // add or delete based on user reaction added or removed
        reaction.setReactionCount(reactionsCountMap);

        reaction.setLastModifiedOn(new Date());

        reactionRepository.save(reaction);
        return true;
    }
Enter fullscreen mode Exit fullscreen mode

4️⃣ Controller Layer

While the controller layer is the entry point for the above requests, not much should be going on in a controller function as per clean code principles.

So you can just look at these and these can be self explanatory :

@GetMapping
    public ResponseEntity<?> getReactionsForEntity(@RequestParam(name = "type") EntityType entityType, @RequestParam(name = "id") String entityId) throws BadRequestException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        String username = authentication.getName();

        if(helper.isNullOrEmpty(entityId)){
            throw new BadRequestException("Reactions can be fetched for specific post/comment only");
        }


        ReactionsDTO reactionsDTO = reactionService
                .getReactions(entityType, entityId, username);
        ReactionsResponseDTO.ReactionsResponseDTOBuilder reactResDTOBuilder =
                ReactionsResponseDTO.builder();
        if(reactionsDTO.userReactions().isEmpty()){
            reactResDTOBuilder.message("No reactions found for user");
        }
        else {
            reactResDTOBuilder.message("Success");
        }
        reactResDTOBuilder.reactions(reactionsDTO);
        return new ResponseEntity<>(reactResDTOBuilder.build(), HttpStatus.OK);
    }

    @PostMapping
    public ResponseEntity<?> postReaction(@RequestBody ReactionRequestDTO reactionRequestDTO) throws BadRequestException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        String username = authentication.getName();

        if(helper.isNullOrEmpty(reactionRequestDTO.entityId())){
            throw new BadRequestException("Entity ID is empty");
        }

        reactionService.toggleReaction(reactionRequestDTO, username);
        return new ResponseEntity<>(new ResponseDTO(
                HttpStatus.OK.getReasonPhrase(),
                "Reaction updated successfully"),
                HttpStatus.OK);
    }
Enter fullscreen mode Exit fullscreen mode

5️⃣ Flow Diagram

All these steps can be understood in a simple diagrams as below :

Reactions frontend Flow


Reactions Backend Flow

Final Thoughts

While breaking down a feature from an existing website you love, seems tough but combined with a little perseverance and a little research helps you understand the level of engineering and thinking going on behind even the simplest of things.

Now everytime you click on that reaction button on Dev, you know somewhat of what is going on.

Top comments (0)