Skip to content

Social Feed Component Recipes

A React Native guide for coding agents. Also covers twitter clone react native, social media feed, post card, people directory, follow button, like repost reply, and 1 more.

Show all 7 aliases

twitter clone react native, social media feed, post card, people directory, follow button, like repost reply, post composer

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.

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: "social-feed-component-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