Skip to content

Production Reaction System Recipes

A React Native guide for coding agents. Also covers animated reaction bar, emoji reaction pills, optimistic message reactions, who reacted bottom sheet, tanstack query reaction cache, chat reaction picker.

A reaction system is more than a row of emoji. It needs a stable server contract, accessible controls, optimistic updates across every cached copy of a message, and a way to inspect who reacted. Keep the visual bar independent from the mutation and cache layers.

Compatibility

These examples target Expo SDK 55 to 57, React Native 0.83 or newer, TanStack Query 5, and Reanimated 4. The details view uses the built-in React Native Modal; an app already using @gorhom/bottom-sheet 5 can replace that presentation without changing the component contract.

These examples target Expo 55+, React Native 0.83+, TanStack Query 5, Reanimated 4, and Bottom Sheet 5. Confirm the installed versions before applying them.

Normalize Raw Events Once

Keep individual reaction events for authorization and audit history. Derive summaries before rendering so recycled message rows do not repeatedly group the same data.

export type ReactionPerson = {
  id: string;
  displayName: string;
  avatarUrl: string | null;
};

export type ReactionEvent = {
  emoji: string;
  person: ReactionPerson;
  createdAt: string;
};

export type ReactionSummary = {
  emoji: string;
  count: number;
  reactedByViewer: boolean;
  people: readonly ReactionPerson[];
};

export function summarizeReactions(
  events: readonly ReactionEvent[],
  viewerId: string,
): ReactionSummary[] {
  const groups = new Map<string, ReactionPerson[]>();

  for (const event of events) {
    const people = groups.get(event.emoji) ?? [];
    if (!people.some((person) => person.id === event.person.id)) {
      people.push(event.person);
      groups.set(event.emoji, people);
    }
  }

  return [...groups].map(([emoji, people]) => ({
    emoji,
    count: people.length,
    reactedByViewer: people.some((person) => person.id === viewerId),
    people,
  }));
}

The server should enforce one row per (messageId, userId, emoji). A toggle endpoint should accept the desired final state, such as { emoji, active }, instead of asking the server to invert unknown state.

Render Animated Pills With Accessible State

The visible pill may be compact, but its press target should remain at least 44 by 44 points. A regular press toggles the reaction. A long press may open details, but the details action must also be available through an accessibility action or another visible control.

import * as Haptics from "expo-haptics";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, {
  FadeIn,
  FadeOut,
  LinearTransition,
  ReduceMotion,
} from "react-native-reanimated";

type ReactionPalette = {
  border: string;
  surface: string;
  selectedBorder: string;
  selectedSurface: string;
  foreground: string;
  selectedForeground: string;
};

type ReactionBarProps = {
  reactions: readonly ReactionSummary[];
  palette: ReactionPalette;
  onToggle(emoji: string, active: boolean): void;
  onAdd(): void;
  onShowPeople(reaction: ReactionSummary): void;
};

const pillTransition = LinearTransition.duration(160).reduceMotion(
  ReduceMotion.System,
);
const pillEnter = FadeIn.duration(140).reduceMotion(ReduceMotion.System);
const pillExit = FadeOut.duration(100).reduceMotion(ReduceMotion.System);

export function ReactionBar({
  reactions,
  palette,
  onToggle,
  onAdd,
  onShowPeople,
}: ReactionBarProps) {
  return (
    <View accessibilityLabel="Message reactions" accessibilityRole="toolbar" style={styles.row}>
      {reactions.map((reaction) => (
        <Animated.View
          entering={pillEnter}
          exiting={pillExit}
          key={reaction.emoji}
          layout={pillTransition}
        >
          <Pressable
            accessibilityActions={[{ name: "showDetails", label: "Show who reacted" }]}
            accessibilityLabel={`${reaction.emoji}, ${reaction.count} ${reaction.count === 1 ? "reaction" : "reactions"}`}
            accessibilityRole="button"
            accessibilityState={{ selected: reaction.reactedByViewer }}
            hitSlop={4}
            onAccessibilityAction={(event) => {
              if (event.nativeEvent.actionName === "showDetails") onShowPeople(reaction);
            }}
            onLongPress={() => onShowPeople(reaction)}
            onPress={() => {
              void Haptics.selectionAsync();
              onToggle(reaction.emoji, !reaction.reactedByViewer);
            }}
            style={styles.target}
          >
            <View
              style={[
                styles.pill,
                { backgroundColor: palette.surface, borderColor: palette.border },
                reaction.reactedByViewer
                  ? {
                      backgroundColor: palette.selectedSurface,
                      borderColor: palette.selectedBorder,
                    }
                  : null,
              ]}
            >
              <Text style={styles.emoji}>{reaction.emoji}</Text>
              <Text
                style={{
                  color: reaction.reactedByViewer
                    ? palette.selectedForeground
                    : palette.foreground,
                }}
              >
                {reaction.count}
                {reaction.reactedByViewer ? " · You" : ""}
              </Text>
            </View>
          </Pressable>
        </Animated.View>
      ))}

      <Pressable
        accessibilityLabel="Add a reaction"
        accessibilityRole="button"
        onPress={onAdd}
        style={styles.target}
      >
        <View style={[styles.pill, { backgroundColor: palette.surface, borderColor: palette.border }]}>
          <Text style={{ color: palette.foreground }}>Add</Text>
        </View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: "row", flexWrap: "wrap", gap: 2 },
  target: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" },
  pill: {
    minHeight: 30,
    flexDirection: "row",
    alignItems: "center",
    gap: 4,
    borderWidth: 1,
    borderRadius: 15,
    borderCurve: "continuous",
    paddingHorizontal: 9,
  },
  emoji: { fontSize: 15 },
});

Keep animation definitions outside the component so they are not rebuilt for every recycled row. The transitions honor the operating-system reduced-motion preference.

Show Who Reacted Without Making Long Press Mandatory

Use a native page sheet for a portable implementation. Mount it once near the screen root rather than once inside every message row.

import { useCallback } from "react";
import {
  FlatList,
  Modal,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export function ReactionPeopleSheet({
  reaction,
  viewerId,
  visible,
  onClose,
  onOpenProfile,
}: {
  reaction: ReactionSummary | null;
  viewerId: string;
  visible: boolean;
  onClose(): void;
  onOpenProfile(personId: string): void;
}) {
  const renderPerson = useCallback(
    ({ item }: { item: ReactionPerson }) => (
      <Pressable
        accessibilityLabel={`Open ${item.displayName}'s profile`}
        accessibilityRole="button"
        onPress={() => onOpenProfile(item.id)}
        style={sheetStyles.person}
      >
        <Text numberOfLines={1} style={sheetStyles.personName}>
          {item.id === viewerId ? "You" : item.displayName}
        </Text>
      </Pressable>
    ),
    [onOpenProfile, viewerId],
  );

  return (
    <Modal
      animationType="slide"
      onRequestClose={onClose}
      presentationStyle="pageSheet"
      visible={visible}
    >
      <SafeAreaView style={sheetStyles.screen}>
        <View style={sheetStyles.header}>
          <Text accessibilityRole="header" style={sheetStyles.title}>
            {reaction
              ? `${reaction.emoji} ${reaction.count} ${reaction.count === 1 ? "person" : "people"}`
              : "Reactions"}
          </Text>
          <Pressable accessibilityRole="button" onPress={onClose} style={sheetStyles.close}>
            <Text>Close</Text>
          </Pressable>
        </View>
        <FlatList
          data={reaction?.people ?? []}
          keyExtractor={(person) => person.id}
          ListEmptyComponent={<Text style={sheetStyles.empty}>No reactions to show</Text>}
          renderItem={renderPerson}
        />
      </SafeAreaView>
    </Modal>
  );
}

const sheetStyles = StyleSheet.create({
  screen: { flex: 1 },
  header: {
    minHeight: 56,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
  },
  title: { flex: 1, fontSize: 17, fontWeight: "700" },
  close: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" },
  person: { minHeight: 52, justifyContent: "center", paddingHorizontal: 16 },
  personName: { fontSize: 16, fontWeight: "600" },
  empty: { padding: 16 },
});

If the people list is private, fetch it only when the sheet opens and authorize that request on the server. Do not place sensitive user details into a push event or public message payload.

Update Every Cached Copy Optimistically

A message can appear in a timeline, search result, thread, and detail screen at the same time. Use one pure patch function for all query families, snapshot every changed cache, and restore every snapshot on failure.

import {
  type InfiniteData,
  useMutation,
  useQueryClient,
} from "@tanstack/react-query";

type ReactionMessage = {
  id: string;
  reactions: readonly ReactionEvent[];
};

type MessagePage = {
  items: ReactionMessage[];
  nextCursor: string | null;
};

type MessageThread = {
  parent: ReactionMessage;
  replies: ReactionMessage[];
};

type ReactionVariables = {
  messageId: string;
  emoji: string;
  active: boolean;
  viewer: ReactionPerson;
};

type ReactionInput = Omit<ReactionVariables, "messageId">;

function patchMessage(
  message: ReactionMessage,
  variables: ReactionVariables,
): ReactionMessage {
  if (message.id !== variables.messageId) return message;

  const withoutViewer = message.reactions.filter(
    (reaction) =>
      !(reaction.emoji === variables.emoji && reaction.person.id === variables.viewer.id),
  );

  return {
    ...message,
    reactions: variables.active
      ? [
          ...withoutViewer,
          {
            emoji: variables.emoji,
            person: variables.viewer,
            createdAt: new Date().toISOString(),
          },
        ]
      : withoutViewer,
  };
}

function patchTimeline(
  data: InfiniteData<MessagePage> | undefined,
  variables: ReactionVariables,
): InfiniteData<MessagePage> | undefined {
  if (!data) return data;

  return {
    ...data,
    pages: data.pages.map((page) => ({
      ...page,
      items: page.items.map((message) => patchMessage(message, variables)),
    })),
  };
}

function patchThread(
  data: MessageThread | undefined,
  variables: ReactionVariables,
): MessageThread | undefined {
  if (!data) return data;
  return {
    parent: patchMessage(data.parent, variables),
    replies: data.replies.map((message) => patchMessage(message, variables)),
  };
}

export function useToggleReaction(
  messageId: string,
  commitReaction: (variables: ReactionVariables) => Promise<void>,
) {
  const queryClient = useQueryClient();

  return useMutation({
    scope: { id: `message-reaction:${messageId}` },
    mutationFn: (input: ReactionInput) => commitReaction({ messageId, ...input }),
    onMutate: async (input: ReactionInput) => {
      const variables: ReactionVariables = { messageId, ...input };
      await Promise.all([
        queryClient.cancelQueries({ queryKey: ["message-timelines"] }),
        queryClient.cancelQueries({ queryKey: ["message-threads"] }),
        queryClient.cancelQueries({ queryKey: ["message", variables.messageId] }),
      ]);

      const previousTimelines = queryClient.getQueriesData<InfiniteData<MessagePage>>({
        queryKey: ["message-timelines"],
      });
      const previousThreads = queryClient.getQueriesData<MessageThread>({
        queryKey: ["message-threads"],
      });
      const detailKey = ["message", variables.messageId] as const;
      const previousDetail = queryClient.getQueryData<ReactionMessage>(detailKey);

      queryClient.setQueriesData<InfiniteData<MessagePage>>(
        { queryKey: ["message-timelines"] },
        (current) => patchTimeline(current, variables),
      );
      queryClient.setQueriesData<MessageThread>(
        { queryKey: ["message-threads"] },
        (current) => patchThread(current, variables),
      );
      queryClient.setQueryData<ReactionMessage>(detailKey, (current) =>
        current ? patchMessage(current, variables) : current,
      );

      return {
        variables,
        previousTimelines,
        previousThreads,
        previousDetail,
        detailKey,
      };
    },
    onError: (_error, _input, snapshot) => {
      for (const [queryKey, data] of snapshot?.previousTimelines ?? []) {
        queryClient.setQueryData(queryKey, data);
      }
      for (const [queryKey, data] of snapshot?.previousThreads ?? []) {
        queryClient.setQueryData(queryKey, data);
      }
      if (snapshot?.previousDetail) {
        queryClient.setQueryData(snapshot.detailKey, snapshot.previousDetail);
      }
    },
    onSettled: (_data, _error, _input, snapshot) => {
      if (!snapshot) return;
      void queryClient.invalidateQueries({ queryKey: ["message-timelines"] });
      void queryClient.invalidateQueries({ queryKey: ["message-threads"] });
      void queryClient.invalidateQueries({
        queryKey: ["message", snapshot.variables.messageId],
      });
    },
  });
}

Each mounted message hook receives its stable messageId. TanStack Query runs mutations with the same scope.id serially, so two fast updates to one message cannot restore overlapping snapshots; reactions on different messages still run in parallel. Apply the same patchMessage function to search query families if they use a different cache shape. Bind commitReaction to the app's authenticated API client. Do not rely on a refetch alone for rollback: the user may still be offline.

Reaction Invariants

  • Treat the requested active value as authoritative; do not send an ambiguous toggle command.
  • Keep the reaction picker and people sheet outside recycled rows.
  • Use haptics as optional confirmation, never as the only feedback.
  • Announce selected state and counts to assistive technology.
  • Provide a visible or accessibility action for details; long press alone is not discoverable.
  • Retry with the same desired final state. Server uniqueness makes that retry idempotent.
  • Reconcile after reconnect because realtime events can arrive late, duplicate, or out of order.

Sources

The component and cache patterns follow current TanStack Query optimistic update guidance, React Native accessibility APIs, React Native Modal, and Reanimated layout transitions. All code is independently written.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-react-native-guide({ topic: "reaction-system-recipes" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems