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.
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" },
];
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,
...
}
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);
};
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>
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 :
- the
entityId
which is used to identify the Post on which the reaction is made, - the
reaction
made, and - 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
}
},
})
}
The frontend is ready, but wait...
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 inMongoDB ⛃
. - 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 :
- The
entityId
andentityType
for the current reaction document. In this case, entityId refers to the post ID and entity type isPOST
. - The
Reactions map
which stores the reactions for that post for all the users, with theUser ID as the key
andall reactions for that particular user in a set
. - 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;
}
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) {
}
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) {
}
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:
- Uses MongoDB's find operation with projection
- The
value
parameter specifies the filter criteria (like WHERE clause in SQL) - 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);
Once the data designs are ready, let's work on the real action.
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());
}
This is relatively simple, compared to the toggle reaction service method below.
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.
Retrieve or Initialize Reaction – Fetches the reaction entry; if absent, creates a new one with default values.
Toggle User Reaction – Adds the reaction if absent (
toggle = 1
), else removes it (toggle = -1
).Update Storage – Removes empty reaction sets to optimize memory.
Adjust Reaction Count – Updates the count, ensuring it doesn’t go below zero.
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;
}
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);
}
5️⃣ Flow Diagram
All these steps can be understood in a simple diagrams as below :
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)