diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js
--- a/lib/shared/chat-message-item-utils.js
+++ b/lib/shared/chat-message-item-utils.js
@@ -1,9 +1,40 @@
 // @flow
 
+import { messageKey } from './message-utils.js';
 import type { ReactionInfo } from '../selectors/chat-selectors.js';
 import { getMessageLabel } from '../shared/edit-messages-utils.js';
+import type {
+  RobotextMessageInfo,
+  ComposableMessageInfo,
+} from '../types/message-types.js';
 import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js';
 
+type ChatMessageItemMessageInfo = ComposableMessageInfo | RobotextMessageInfo;
+
+// This complicated type matches both ChatMessageItem and
+// ChatMessageItemWithHeight, and is a disjoint union of types
+type BaseChatMessageInfoItem = {
+  +itemType: 'message',
+  +messageInfo: ChatMessageItemMessageInfo,
+  +messageInfos?: ?void,
+  ...
+};
+type BaseChatMessageItem =
+  | BaseChatMessageInfoItem
+  | {
+      +itemType: 'loader',
+      +messageInfo?: ?void,
+      +messageInfos?: ?void,
+      ...
+    };
+
+function chatMessageItemKey(item: BaseChatMessageItem): string {
+  if (item.itemType === 'loader') {
+    return 'loader';
+  }
+  return messageKey(item.messageInfo);
+}
+
 type BaseChatMessageItemForEngagementCheck = {
   +threadCreatedFromMessage: ?ThreadInfo,
   +reactions: ReactionInfo,
@@ -22,4 +53,4 @@
   );
 }
 
-export { chatMessageItemHasEngagement };
+export { chatMessageItemKey, chatMessageItemHasEngagement };
diff --git a/native/chat/chat-item-height-measurer.react.js b/native/chat/chat-item-height-measurer.react.js
--- a/native/chat/chat-item-height-measurer.react.js
+++ b/native/chat/chat-item-height-measurer.react.js
@@ -3,6 +3,7 @@
 import invariant from 'invariant';
 import * as React from 'react';
 
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import { getMessageLabel } from 'lib/shared/edit-messages-utils.js';
 import {
   getInlineEngagementSidebarText,
@@ -23,7 +24,6 @@
 import type { NativeChatMessageItem } from './message-data.react.js';
 import { MessageListContextProvider } from './message-list-types.js';
 import { multimediaMessageContentSizes } from './multimedia-message-utils.js';
-import { chatMessageItemKey } from './utils.js';
 import NodeHeightMeasurer from '../components/node-height-measurer.react.js';
 import { InputStateContext } from '../input/input-state.js';
 
diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js
--- a/native/chat/chat-list.react.js
+++ b/native/chat/chat-list.react.js
@@ -18,11 +18,12 @@
 } from 'react-native';
 import { FlatList } from 'react-native-gesture-handler';
 
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import { localIDPrefix } from 'lib/shared/message-utils.js';
 
 import type { ChatNavigationProp } from './chat.react.js';
 import NewMessagesPill from './new-messages-pill.react.js';
-import { chatMessageItemHeight, chatMessageItemKey } from './utils.js';
+import { chatMessageItemHeight } from './utils.js';
 import { InputStateContext } from '../input/input-state.js';
 import type { InputState } from '../input/input-state.js';
 import {
diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js
--- a/native/chat/message-list.react.js
+++ b/native/chat/message-list.react.js
@@ -6,7 +6,8 @@
 import { TouchableWithoutFeedback, View } from 'react-native';
 import { createSelector } from 'reselect';
 
-import { messageKey, useFetchMessages } from 'lib/shared/message-utils.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
+import { useFetchMessages } from 'lib/shared/message-utils.js';
 import { useWatchThread } from 'lib/shared/watch-thread-utils.js';
 import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
 import { threadTypeIsPersonal } from 'lib/types/thread-types-enum.js';
@@ -165,8 +166,7 @@
     const messageInfoItem: ChatMessageInfoItemWithHeight = row.item;
     const { messageListVerticalBounds, focusedMessageKey, navigation, route } =
       this.flatListExtraData;
-    const focused =
-      messageKey(messageInfoItem.messageInfo) === focusedMessageKey;
+    const focused = chatMessageItemKey(messageInfoItem) === focusedMessageKey;
     return (
       <Message
         item={messageInfoItem}
@@ -261,7 +261,7 @@
       for (const token of info.viewableItems) {
         if (
           token.item.itemType === 'message' &&
-          messageKey(token.item.messageInfo) === this.state.focusedMessageKey
+          chatMessageItemKey(token.item) === this.state.focusedMessageKey
         ) {
           focusedMessageVisible = true;
           break;
diff --git a/native/chat/message.react.js b/native/chat/message.react.js
--- a/native/chat/message.react.js
+++ b/native/chat/message.react.js
@@ -7,7 +7,7 @@
   PixelRatio,
 } from 'react-native';
 
-import { messageKey } from 'lib/shared/message-utils.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import { useCanToggleMessagePin } from 'lib/utils/message-pinning-utils.js';
 
 import type { ChatNavigationProp } from './chat.react.js';
@@ -82,7 +82,7 @@
 
       console.log(
         `Message height for ${item.messageShapeType} ` +
-          `${messageKey(item.messageInfo)} was expected to be ` +
+          `${chatMessageItemKey(item)} was expected to be ` +
           `${approxExpectedHeight} but is actually ${approxMeasuredHeight}. ` +
           "This means MessageList's FlatList isn't getting the right item " +
           'height for some of its nodes, which is guaranteed to cause glitchy ' +
diff --git a/native/chat/pinned-messages-screen.react.js b/native/chat/pinned-messages-screen.react.js
--- a/native/chat/pinned-messages-screen.react.js
+++ b/native/chat/pinned-messages-screen.react.js
@@ -10,6 +10,7 @@
   type ChatMessageInfoItem,
   messageListData,
 } from 'lib/selectors/chat-selectors.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import {
   createMessageInfo,
   isInvalidPinSourceForThread,
@@ -162,7 +163,7 @@
 
         return (
           <MessageResult
-            key={item.messageInfo.id}
+            key={chatMessageItemKey(item)}
             item={item}
             threadInfo={threadInfo}
             navigation={navigation}
diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js
--- a/native/chat/robotext-message.react.js
+++ b/native/chat/robotext-message.react.js
@@ -4,8 +4,10 @@
 import * as React from 'react';
 import { View } from 'react-native';
 
-import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js';
-import { messageKey } from 'lib/shared/message-utils.js';
+import {
+  chatMessageItemKey,
+  chatMessageItemHasEngagement,
+} from 'lib/shared/chat-message-item-utils.js';
 import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js';
 
 import { inlineEngagementCenterStyle } from './chat-constants.js';
@@ -79,7 +81,7 @@
 
   const chatContext = React.useContext(ChatContext);
   const keyboardState = React.useContext(KeyboardContext);
-  const key = messageKey(item.messageInfo);
+  const key = chatMessageItemKey(item);
   const onPress = React.useCallback(() => {
     const didDismiss =
       keyboardState && keyboardState.dismissKeyboardIfShowing();
@@ -181,7 +183,7 @@
     }
 
     if (!focused) {
-      toggleFocus(messageKey(item.messageInfo));
+      toggleFocus(key);
     }
 
     invariant(overlayContext, 'RobotextMessage should have OverlayContext');
@@ -189,7 +191,7 @@
     viewRef.current?.measure(openRobotextTooltipModal);
   }, [
     focused,
-    item,
+    key,
     keyboardState,
     overlayContext,
     toggleFocus,
diff --git a/native/chat/utils.js b/native/chat/utils.js
--- a/native/chat/utils.js
+++ b/native/chat/utils.js
@@ -6,18 +6,15 @@
 
 import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js';
 import { useThreadChatMentionCandidates } from 'lib/hooks/chat-mention-hooks.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import { colorIsDark } from 'lib/shared/color-utils.js';
-import { messageKey } from 'lib/shared/message-utils.js';
 import { viewerIsMember } from 'lib/shared/thread-utils.js';
 import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
 
 import { clusterEndHeight } from './chat-constants.js';
 import { ChatContext, useHeightMeasurer } from './chat-context.js';
 import { failedSendHeight } from './failed-send.react.js';
-import {
-  type NativeChatMessageItem,
-  useNativeMessageListData,
-} from './message-data.react.js';
+import { useNativeMessageListData } from './message-data.react.js';
 import { authorNameHeight } from './message-header.react.js';
 import { multimediaMessageItemHeight } from './multimedia-message-utils.js';
 import { getUnresolvedSidebarThreadInfo } from './sidebar-navigation.js';
@@ -312,7 +309,7 @@
 }
 
 function getMessageTooltipKey(item: ChatMessageInfoItemWithHeight): string {
-  return `tooltip|${messageKey(item.messageInfo)}`;
+  return `tooltip|${chatMessageItemKey(item)}`;
 }
 
 function isMessageTooltipKey(key: string): boolean {
@@ -383,15 +380,6 @@
   ]);
 }
 
-function chatMessageItemKey(
-  item: ChatMessageItemWithHeight | NativeChatMessageItem,
-): string {
-  if (item.itemType === 'loader') {
-    return 'loader';
-  }
-  return messageKey(item.messageInfo);
-}
-
 function modifyItemForResultScreen(
   item: ChatMessageInfoItemWithHeight,
 ): ChatMessageInfoItemWithHeight {
@@ -431,7 +419,6 @@
 }
 
 export {
-  chatMessageItemKey,
   chatMessageItemHeight,
   useAnimatedMessageTooltipButton,
   messageItemHeight,
diff --git a/native/search/message-search.react.js b/native/search/message-search.react.js
--- a/native/search/message-search.react.js
+++ b/native/search/message-search.react.js
@@ -6,6 +6,7 @@
 import { FlatList } from 'react-native-gesture-handler';
 
 import { messageListData } from 'lib/selectors/chat-selectors.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import { createMessageInfo } from 'lib/shared/message-utils.js';
 import {
   filterChatMessageInfosForSearch,
@@ -168,7 +169,7 @@
       }
       return (
         <MessageResult
-          key={item.messageInfo.id}
+          key={chatMessageItemKey(item)}
           item={item}
           threadInfo={threadInfo}
           navigation={props.navigation}
diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js
--- a/web/chat/chat-message-list.react.js
+++ b/web/chat/chat-message-list.react.js
@@ -11,7 +11,8 @@
   type ChatMessageItem,
   useMessageListData,
 } from 'lib/selectors/chat-selectors.js';
-import { messageKey, useFetchMessages } from 'lib/shared/message-utils.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
+import { useFetchMessages } from 'lib/shared/message-utils.js';
 import {
   threadIsPending,
   threadOtherMembers,
@@ -108,8 +109,8 @@
       return true;
     }
     return (
-      ChatMessageList.keyExtractor(prevMessageListData[0]) !==
-      ChatMessageList.keyExtractor(messageListData[0])
+      chatMessageItemKey(prevMessageListData[0]) !==
+      chatMessageItemKey(messageListData[0])
     );
   }
 
@@ -164,13 +165,6 @@
     }
   }
 
-  static keyExtractor(item: ChatMessageItem): string {
-    if (item.itemType === 'loader') {
-      return 'loader';
-    }
-    return messageKey(item.messageInfo);
-  }
-
   renderItem = (item: ChatMessageItem): React.Node => {
     if (item.itemType === 'loader') {
       return (
@@ -186,7 +180,7 @@
         item={item}
         threadInfo={threadInfo}
         shouldDisplayPinIndicator={true}
-        key={ChatMessageList.keyExtractor(item)}
+        key={chatMessageItemKey(item)}
       />
     );
   };
diff --git a/web/components/message-result.react.js b/web/components/message-result.react.js
--- a/web/components/message-result.react.js
+++ b/web/components/message-result.react.js
@@ -55,7 +55,6 @@
               item={item}
               threadInfo={threadInfo}
               shouldDisplayPinIndicator={false}
-              key={item.messageInfo.id}
             />
           </MessageListContext.Provider>
         </div>
diff --git a/web/modals/chat/pinned-messages-modal.react.js b/web/modals/chat/pinned-messages-modal.react.js
--- a/web/modals/chat/pinned-messages-modal.react.js
+++ b/web/modals/chat/pinned-messages-modal.react.js
@@ -12,6 +12,7 @@
   messageListData,
 } from 'lib/selectors/chat-selectors.js';
 import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import {
   createMessageInfo,
   isInvalidPinSourceForThread,
@@ -136,7 +137,7 @@
 
     const items = modifiedItems.map(item => (
       <MessageResult
-        key={item.messageInfo.id}
+        key={chatMessageItemKey(item)}
         item={item}
         threadInfo={threadInfo}
         scrollable={false}
diff --git a/web/modals/search/message-search-modal.react.js b/web/modals/search/message-search-modal.react.js
--- a/web/modals/search/message-search-modal.react.js
+++ b/web/modals/search/message-search-modal.react.js
@@ -4,6 +4,7 @@
 
 import { useModalContext } from 'lib/components/modal-provider.react.js';
 import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js';
+import { chatMessageItemKey } from 'lib/shared/chat-message-item-utils.js';
 import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
 import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js';
 
@@ -79,7 +80,7 @@
   const renderItem = React.useCallback(
     (item: ChatMessageInfoItem) => (
       <MessageResult
-        key={item.messageInfo.id}
+        key={chatMessageItemKey(item)}
         item={item}
         threadInfo={threadInfo}
         scrollable={false}