A Twitter-style product is a timeline plus a set of independently testable social actions. Normalize the data first, keep mutation ownership outside the visual card, and make every action usable without relying on icons, color, long-press, or swipe.
The colors below are readable example values, not a new brand. Replace them with the host app's semantic foreground, surface, border, primary, danger, and disabled tokens, including dark-mode variants.
Normalize the Feed Contract
Counts and viewer state arrive together so optimistic transitions cannot drift into contradictory combinations.
export type SocialMedia = {
id: string;
kind: "image" | "video";
url: string;
thumbnailUrl: string;
alt: string;
aspectRatio: number;
};
export type SocialPost = {
id: string;
authorId: string;
authorName: string;
authorHandle: string;
authorAvatarUrl: string | null;
body: string;
createdAt: string;
media: readonly SocialMedia[];
replyCount: number;
repostCount: number;
likeCount: number;
likedByViewer: boolean;
repostedByViewer: boolean;
bookmarkedByViewer: boolean;
};
Use cursors for feed pagination. Offset pagination shifts when new posts arrive and creates duplicates or gaps.
Render Media With Recycling-Safe Images
Request server-sized thumbnails for the rendered slot. The full-resolution asset belongs in the media viewer, not in every feed cell.
import { Pressable, StyleSheet, View } from "react-native";
import { Image } from "expo-image";
export function PostMediaGrid({
postId,
media,
onOpen,
}: {
postId: string;
media: readonly SocialMedia[];
onOpen(index: number): void;
}) {
const visible = media.slice(0, 4);
if (visible.length === 0) return null;
return (
<View style={mediaStyles.grid}>
{visible.map((item, index) => (
<Pressable
key={item.id}
accessibilityLabel={`${item.alt}. Open image ${index + 1} of ${visible.length}`}
accessibilityRole="imagebutton"
onPress={() => onOpen(index)}
style={visible.length === 1 ? mediaStyles.singleTarget : mediaStyles.tileTarget}
>
<Image
source={{ uri: item.thumbnailUrl }}
alt=""
cachePolicy="memory-disk"
contentFit="cover"
recyclingKey={`${postId}:${item.id}`}
style={mediaStyles.image}
/>
</Pressable>
))}
</View>
);
}
const mediaStyles = StyleSheet.create({
grid: { flexDirection: "row", flexWrap: "wrap", gap: 2, overflow: "hidden", borderRadius: 14, borderCurve: "continuous" },
singleTarget: { width: "100%", height: 280 },
tileTarget: { width: "49%", height: 150, flexGrow: 1 },
image: { width: "100%", height: "100%", backgroundColor: "#ECECF1" },
});
Alt text should describe meaningful media. Use an empty alt value for a purely decorative image, but do not omit descriptions for user-authored content when the product supports them.
Build a Complete Social Action Bar
Accept counts and state as props. This makes the component work with a reducer, TanStack Query, GraphQL cache, or local-first store.
import { Pressable, StyleSheet, Text, View } from "react-native";
type SocialAction = "reply" | "repost" | "like" | "bookmark";
function ActionButton({
action,
count,
selected,
onPress,
}: {
action: SocialAction;
count?: number;
selected?: boolean;
onPress(action: SocialAction): void;
}) {
const defaultLabel = action[0].toUpperCase() + action.slice(1);
const label = selected
? action === "like"
? "Unlike"
: action === "repost"
? "Undo repost"
: action === "bookmark"
? "Remove bookmark"
: defaultLabel
: defaultLabel;
const value = count && count > 0 ? `, ${count}` : "";
return (
<Pressable
accessibilityLabel={`${label}${value}`}
accessibilityRole="button"
accessibilityState={{ selected: selected ?? false }}
onPress={() => onPress(action)}
style={actionStyles.target}
>
<Text style={[actionStyles.label, selected ? actionStyles.selected : null]}>{label}{value}</Text>
</Pressable>
);
}
export function SocialActionBar({
post,
onAction,
}: {
post: Pick<SocialPost, "id" | "replyCount" | "repostCount" | "likeCount" | "likedByViewer" | "repostedByViewer" | "bookmarkedByViewer">;
onAction(postId: string, action: SocialAction): void;
}) {
const press = (action: SocialAction) => onAction(post.id, action);
return (
<View accessibilityRole="toolbar" style={actionStyles.row}>
<ActionButton action="reply" count={post.replyCount} onPress={press} />
<ActionButton action="repost" count={post.repostCount} selected={post.repostedByViewer} onPress={press} />
<ActionButton action="like" count={post.likeCount} selected={post.likedByViewer} onPress={press} />
<ActionButton action="bookmark" selected={post.bookmarkedByViewer} onPress={press} />
</View>
);
}
const actionStyles = StyleSheet.create({
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
target: { minHeight: 44, minWidth: 44, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
label: { color: "#575761", fontSize: 12, fontWeight: "600" },
selected: { color: "#2F5BEA" },
});
An icon library can replace the visible text labels, but keep the explicit accessibility label and selected state.
Compose a Recyclable Post Card
The card receives primitive fields where possible. Media remains a stable readonly array from the normalized cache.
import { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Image } from "expo-image";
type PostCardProps = SocialPost & {
onOpenPost(id: string): void;
onOpenAuthor(id: string): void;
onOpenMedia(postId: string, index: number): void;
onAction(postId: string, action: "reply" | "repost" | "like" | "bookmark"): void;
};
export const PostCard = memo(function PostCard({
id,
authorId,
authorName,
authorHandle,
authorAvatarUrl,
body,
createdAt,
media,
onOpenPost,
onOpenAuthor,
onOpenMedia,
onAction,
...socialState
}: PostCardProps) {
const timestamp = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(createdAt));
return (
<View style={postStyles.card}>
<View style={postStyles.row}>
<Pressable accessibilityLabel={`Open ${authorName}'s profile`} accessibilityRole="button" onPress={() => onOpenAuthor(authorId)} style={postStyles.avatarTarget}>
{authorAvatarUrl ? (
<Image source={{ uri: authorAvatarUrl }} alt="" cachePolicy="memory-disk" recyclingKey={`${id}:${authorAvatarUrl}`} style={postStyles.avatar} />
) : (
<View style={postStyles.avatarFallback}><Text>{authorName.slice(0, 1).toUpperCase()}</Text></View>
)}
</Pressable>
<View style={postStyles.content}>
<Pressable accessibilityLabel={`Open post by ${authorName}`} accessibilityRole="button" onPress={() => onOpenPost(id)} style={postStyles.openPost}>
<View style={postStyles.header}>
<Text numberOfLines={1} style={postStyles.author}>{authorName}</Text>
<Text numberOfLines={1} style={postStyles.meta}>@{authorHandle} · {timestamp}</Text>
</View>
<Text style={postStyles.body}>{body}</Text>
</Pressable>
<PostMediaGrid postId={id} media={media} onOpen={(index) => onOpenMedia(id, index)} />
<SocialActionBar post={{ id, ...socialState }} onAction={onAction} />
</View>
</View>
</View>
);
});
const postStyles = StyleSheet.create({
card: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#D7D7DE", backgroundColor: "#FFFFFF" },
row: { flexDirection: "row", alignItems: "flex-start", gap: 8 },
avatarTarget: { minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center" },
avatar: { width: 40, height: 40, borderRadius: 20 },
avatarFallback: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center", backgroundColor: "#E8E8ED" },
content: { flex: 1, gap: 8 },
openPost: { minHeight: 44, justifyContent: "center" },
header: { flexDirection: "row", alignItems: "center", gap: 4 },
author: { maxWidth: "45%", fontWeight: "700", color: "#111116" },
meta: { flex: 1, color: "#6B6B75" },
body: { fontSize: 16, lineHeight: 22, color: "#111116" },
});
Keep menu, moderation, and ownership checks outside this visual component. The server still authorizes every edit, delete, hide, block, and report action.
Virtualize the Feed
Avoid entrance animations on every card. They delay content, increase work during fast scrolls, and can be uncomfortable for motion-sensitive users.
import { useCallback } from "react";
import { Text } from "react-native";
import { LegendList } from "@legendapp/list/react-native";
export function SocialFeed({
posts,
refreshing,
onRefresh,
onLoadMore,
renderPost,
}: {
posts: readonly SocialPost[];
refreshing: boolean;
onRefresh(): void;
onLoadMore(): void;
renderPost(post: SocialPost): React.ReactElement;
}) {
const renderItem = useCallback(({ item }: { item: SocialPost }) => renderPost(item), [renderPost]);
return (
<LegendList
accessibilityRole="list"
contentInsetAdjustmentBehavior="automatic"
data={posts}
keyExtractor={(post) => post.id}
ListEmptyComponent={<Text>Follow people or write the first post</Text>}
onEndReached={onLoadMore}
onEndReachedThreshold={0.4}
onRefresh={onRefresh}
refreshing={refreshing}
renderItem={renderItem}
/>
);
}
Keep renderPost stable with useCallback when React Compiler is not enabled. Preserve object identity for unchanged normalized posts so memoized rows can skip work.
Build a Post Composer With Honest Limits
Show the actual character limit, preserve the draft on failure, and keep upload state separate from publishing state.
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
const MAX_POST_LENGTH = 500;
export function PostComposer({
uploading,
onAddMedia,
onPublish,
}: {
uploading: boolean;
onAddMedia(): void;
onPublish(body: string): Promise<void>;
}) {
const [draft, setDraft] = useState("");
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null);
const remaining = MAX_POST_LENGTH - draft.length;
const body = draft.trim();
const canPublish = body.length > 0 && remaining >= 0 && !uploading && !publishing;
const publish = async () => {
if (!canPublish) return;
setError(null);
setPublishing(true);
try {
await onPublish(body);
setDraft("");
} catch {
setError("Post not published. Your draft is still here.");
} finally {
setPublishing(false);
}
};
return (
<View style={composeStyles.container}>
<TextInput
accessibilityLabel="Post text"
maxLength={MAX_POST_LENGTH}
multiline
onChangeText={setDraft}
placeholder="What’s happening?"
style={composeStyles.input}
value={draft}
/>
<View style={composeStyles.footer}>
<Pressable accessibilityLabel="Add photos or video" accessibilityRole="button" disabled={uploading} onPress={onAddMedia} style={composeStyles.action}>
<Text>{uploading ? "Uploading…" : "Add media"}</Text>
</Pressable>
<Text accessibilityLabel={`${remaining} characters remaining`} style={remaining < 40 ? composeStyles.warning : composeStyles.count}>{remaining}</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ busy: publishing, disabled: !canPublish }}
disabled={!canPublish}
onPress={publish}
style={[composeStyles.publish, !canPublish ? composeStyles.disabled : null]}
>
<Text style={composeStyles.publishLabel}>{publishing ? "Publishing…" : "Post"}</Text>
</Pressable>
</View>
{error ? <Text accessibilityLiveRegion="polite" style={composeStyles.error}>{error}</Text> : null}
</View>
);
}
const composeStyles = StyleSheet.create({
container: { gap: 8, padding: 12, backgroundColor: "#FFFFFF" },
input: { minHeight: 120, fontSize: 18, lineHeight: 24, textAlignVertical: "top" },
footer: { minHeight: 44, flexDirection: "row", alignItems: "center", gap: 8 },
action: { minHeight: 44, justifyContent: "center", paddingHorizontal: 8 },
count: { marginLeft: "auto", color: "#6B6B75", fontVariant: ["tabular-nums"] },
warning: { marginLeft: "auto", color: "#B54708", fontVariant: ["tabular-nums"] },
publish: { minWidth: 72, minHeight: 44, borderRadius: 22, alignItems: "center", justifyContent: "center", backgroundColor: "#2F5BEA" },
disabled: { opacity: 0.45 },
publishLabel: { color: "#FFFFFF", fontWeight: "700" },
error: { color: "#B42318" },
});
Add a People Row and Follow State
The parent owns optimistic follow state so the same row works in search, followers, following, and suggested-people lists.
import { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Image } from "expo-image";
export const PersonRow = memo(function PersonRow({
id,
name,
handle,
bio,
avatarUrl,
following,
pending,
onOpen,
onToggleFollow,
}: {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
following: boolean;
pending: boolean;
onOpen(id: string): void;
onToggleFollow(id: string, next: boolean): void;
}) {
return (
<View style={personStyles.row}>
<Pressable accessibilityLabel={`Open ${name}'s profile`} accessibilityRole="button" onPress={() => onOpen(id)} style={personStyles.profile}>
{avatarUrl ? <Image source={{ uri: avatarUrl }} alt="" cachePolicy="memory-disk" recyclingKey={`${id}:${avatarUrl}`} style={personStyles.avatar} /> : <View style={personStyles.avatarFallback}><Text>{name.slice(0, 1)}</Text></View>}
<View style={personStyles.copy}>
<Text numberOfLines={1} style={personStyles.name}>{name}</Text>
<Text numberOfLines={1} style={personStyles.handle}>@{handle}</Text>
{bio ? <Text numberOfLines={2} style={personStyles.bio}>{bio}</Text> : null}
</View>
</Pressable>
<Pressable
accessibilityLabel={following ? `Unfollow ${name}` : `Follow ${name}`}
accessibilityRole="button"
accessibilityState={{ busy: pending, selected: following }}
disabled={pending}
onPress={() => onToggleFollow(id, !following)}
style={[personStyles.follow, following ? personStyles.following : null]}
>
<Text style={following ? personStyles.followingLabel : personStyles.followLabel}>{following ? "Following" : "Follow"}</Text>
</Pressable>
</View>
);
});
const personStyles = StyleSheet.create({
row: { minHeight: 72, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingVertical: 8 },
profile: { flex: 1, minHeight: 56, flexDirection: "row", alignItems: "center", gap: 8 },
avatar: { width: 48, height: 48, borderRadius: 24 },
avatarFallback: { width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", backgroundColor: "#E8E8ED" },
copy: { flex: 1 },
name: { fontWeight: "700", color: "#111116" },
handle: { color: "#6B6B75" },
bio: { marginTop: 2, color: "#303038" },
follow: { minWidth: 88, minHeight: 44, paddingHorizontal: 12, borderRadius: 22, alignItems: "center", justifyContent: "center", backgroundColor: "#111116" },
following: { backgroundColor: "#FFFFFF", borderWidth: 1, borderColor: "#B8B8C0" },
followLabel: { color: "#FFFFFF", fontWeight: "700" },
followingLabel: { color: "#111116", fontWeight: "700" },
});
Apply Optimistic Actions With Rollback
Cancel competing refetches, snapshot the prior post, update immediately, and restore the exact snapshot if the request fails.
import { useMutation, useQueryClient } from "@tanstack/react-query";
type LikeInput = { postId: string; nextLiked: boolean };
export function useTogglePostLike(updateLike: (input: LikeInput) => Promise<void>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateLike,
onMutate: async ({ postId, nextLiked }) => {
const key = ["post", postId] as const;
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<SocialPost>(key);
queryClient.setQueryData<SocialPost>(key, (post) => {
if (!post) return post;
const delta = post.likedByViewer === nextLiked ? 0 : nextLiked ? 1 : -1;
return {
...post,
likedByViewer: nextLiked,
likeCount: Math.max(0, post.likeCount + delta),
};
});
return { key, previous };
},
onError: (_error, _input, context) => {
if (context?.previous) queryClient.setQueryData(context.key, context.previous);
},
onSettled: (_data, _error, { postId }) =>
queryClient.invalidateQueries({ queryKey: ["post", postId] }),
});
}
Use the same shape for repost, bookmark, follow, and reaction toggles. Server endpoints must be idempotent and return authoritative state.
Social Product Invariants
- Separate Following, For You, Search, Notifications, and Profile timelines by query key and cursor.
- Deep-link posts, replies, profiles, and notification targets by immutable IDs.
- Keep block, mute, report, and hide available from a clearly labeled menu.
- Do not autoplay feed video when reduced motion or data-saving preferences indicate otherwise.
- Record impressions from viewability thresholds, not when rows are merely fetched.
- Keep deleted or moderated content understandable in conversation threads with a tombstone row.
- Render notification counts as
99+after 99 and never communicate unread state by color alone.
Sources
Feed virtualization follows LegendList and React Native's FlatList behavior. Images follow Expo Image. Control labels and selected/busy states follow React Native accessibility. Optimistic mutation structure follows TanStack Query optimistic updates. All code above is independently written.