DEV Community

Cover image for Phase 5: Building the Timeline View - Lists, Tabs, Modals, and Progress Tracking
Md Enayetur Rahman
Md Enayetur Rahman

Posted on

Phase 5: Building the Timeline View - Lists, Tabs, Modals, and Progress Tracking

The Journey Continues

In Phase 4, I built the complete appointment booking flow with custom dropdowns, search filtering, and conditional UI. Now it's time to build the other side of appointments—viewing appointment history with a timeline that shows upcoming, completed, and cancelled sessions.

Coming from React web development, I was curious: How do you build tab interfaces in React Native? How do modals work? Can you create bottom sheets like native apps? How do you show progress indicators for sessions? Let me walk you through what I learned, comparing everything to what you already know from React.


What We're Building

In Phase 5, I implemented:

  1. Timeline Tab with three filter tabs (Upcoming, Completed, Cancelled)
  2. Search functionality to filter sessions by consultant, type, or date
  3. Session cards with date, consultant info, and status badges
  4. Progress indicators showing exercises completed (e.g., 4/10)
  5. Session details modal with full appointment information
  6. Exercise list with weights, sets, reps, and duration
  7. Reusable components that work in both Timeline and Profile sections

Everything uses mock session data to simulate real appointment history.


Step 1: Project Structure - Adding Session Types

React (Web) - Typical Timeline Structure:

my-react-app/
├── src/
│   ├── components/
│   │   ├── Timeline.js
│   │   ├── SessionCard.js
│   │   └── SessionModal.js
│   └── data/
│       └── sessions.js
Enter fullscreen mode Exit fullscreen mode

React Native (What I Built):

physio-care/
├── src/
│   ├── components/
│   │   ├── screens/
│   │   │   └── TimelineTab.tsx
│   │   └── ui-molecules/
│   │       ├── SessionCard.tsx
│   │       └── AppointmentDetailsSheet.tsx
│   ├── data/
│   │   └── mockSessions.ts
│   └── types/
│       └── session.tsx
Enter fullscreen mode Exit fullscreen mode

Key Differences:

  1. Session types separate from appointments: Different data models for booking vs history
  2. "Sheet" instead of "Modal": Mobile UX pattern for bottom-up content
  3. Tab logic inside TimelineTab: Self-contained filtering and search
  4. Exercise tracking: Sessions include exercise lists with progress

Takeaway: Timeline UIs in mobile apps follow different patterns than web—tabs are more common than dropdowns, and modals slide up from bottom rather than centering on screen.


Step 2: TypeScript Types for Sessions

This was crucial for managing appointment history! Let me show you the session types I created.

React (Web) - JavaScript Objects:

// sessions.js
export const sessions = [
  {
    id: "1",
    date: "2024-12-15",
    time: "10:00 AM",
    consultant: "Dr. Sarah Johnson",
    status: "upcoming",
    exercises: ["Knee Extension", "Hamstring Stretch"],
  },
];
Enter fullscreen mode Exit fullscreen mode

React Native - TypeScript Types:

// src/types/session.tsx
export type SessionStatus = "upcoming" | "completed" | "cancelled";

export interface Exercise {
  id: string;
  name: string;
  weight?: string;
  sets?: number;
  reps?: number;
  duration?: string;
}

export interface Session {
  id: string;
  date: string; // ISO date string
  time: string; // e.g., "10:00 AM"
  type: "In-Clinic" | "Online";
  consultant: {
    id: string;
    name: string;
    specialty: string;
  };
  center?: {
    id: string;
    name: string;
  };
  status: SessionStatus;
  totalExercises: number;
  completedExercises: number;
  duration: string; // e.g., "45 minutes"
  exercises: Exercise[];
  notes?: string;
}
Enter fullscreen mode Exit fullscreen mode

Benefits of These Types:

  1. Progress tracking: completedExercises/totalExercises enables progress bars
  2. Optional fields: center only for in-clinic, notes only when added
  3. Nested objects: Consultant and center info embedded for easy display
  4. Exercise details: Flexible structure for different exercise types
  5. Type safety: TypeScript ensures you handle all session states

Takeaway: Well-structured session types make it easy to calculate progress, filter by status, and display detailed information without additional API calls.


Step 3: Building the Tab Interface

React (Web) - CSS Tabs with State:

// Timeline.js
function Timeline() {
  const [activeTab, setActiveTab] = useState("upcoming");

  return (
    <div className="timeline">
      <div className="tabs">
        <button
          className={activeTab === "upcoming" ? "active" : ""}
          onClick={() => setActiveTab("upcoming")}
        >
          Upcoming
        </button>
        <button
          className={activeTab === "completed" ? "active" : ""}
          onClick={() => setActiveTab("completed")}
        >
          Completed
        </button>
        <button
          className={activeTab === "cancelled" ? "active" : ""}
          onClick={() => setActiveTab("cancelled")}
        >
          Cancelled
        </button>
      </div>
      <div className="content">
        {sessions
          .filter((s) => s.status === activeTab)
          .map((session) => (
            <SessionCard key={session.id} session={session} />
          ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
/* Timeline.css */
.tabs {
  display: flex;
  gap: 8px;
  padding: 12px;
  background: white;
}

.tabs button {
  flex: 1;
  padding: 8px 16px;
  border-radius: 8px;
  border: none;
  background: transparent;
  color: #8e8e93;
}

.tabs button.active {
  background: #007aff;
  color: white;
}
Enter fullscreen mode Exit fullscreen mode

React Native - TouchableOpacity Tabs with useMemo:

// src/components/screens/TimelineTab.tsx
import React, { useState, useMemo } from "react";
import {
  View,
  Text,
  TouchableOpacity,
  FlatList,
  StyleSheet,
} from "react-native";
import { mockSessions } from "../../data/mockSessions";
import SessionCard from "../ui-molecules/SessionCard";

type TabType = "upcoming" | "completed" | "cancelled";

export default function TimelineTab() {
  const [activeTab, setActiveTab] = useState<TabType>("upcoming");
  const [searchQuery, setSearchQuery] = useState("");

  const filteredSessions = useMemo(() => {
    let sessions = mockSessions.filter(
      (session) => session.status === activeTab
    );

    if (searchQuery.trim()) {
      const query = searchQuery.toLowerCase();
      sessions = sessions.filter(
        (session) =>
          session.consultant.name.toLowerCase().includes(query) ||
          session.type.toLowerCase().includes(query) ||
          session.date.includes(query)
      );
    }

    // Sort by date (upcoming first for upcoming, recent first for others)
    return sessions.sort((a, b) => {
      const dateA = new Date(a.date).getTime();
      const dateB = new Date(b.date).getTime();
      return activeTab === "upcoming" ? dateA - dateB : dateB - dateA;
    });
  }, [activeTab, searchQuery]);

  const tabs: { key: TabType; label: string; count: number }[] = [
    {
      key: "upcoming",
      label: "Upcoming",
      count: mockSessions.filter((s) => s.status === "upcoming").length,
    },
    {
      key: "completed",
      label: "Completed",
      count: mockSessions.filter((s) => s.status === "completed").length,
    },
    {
      key: "cancelled",
      label: "Cancelled",
      count: mockSessions.filter((s) => s.status === "cancelled").length,
    },
  ];

  return (
    <View style={styles.container}>
      {/* Header */}
      <View style={styles.header}>
        <Text style={styles.headerTitle}>My Appointments</Text>
      </View>

      {/* Tabs */}
      <View style={styles.tabsContainer}>
        {tabs.map((tab) => (
          <TouchableOpacity
            key={tab.key}
            style={[styles.tab, activeTab === tab.key && styles.activeTab]}
            onPress={() => setActiveTab(tab.key)}
          >
            <Text
              style={[
                styles.tabText,
                activeTab === tab.key && styles.activeTabText,
              ]}
            >
              {tab.label} ({tab.count})
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      {/* Sessions List */}
      <FlatList
        data={filteredSessions}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <SessionCard session={item} />}
        contentContainerStyle={styles.listContainer}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#F2F2F7",
  },
  header: {
    backgroundColor: "white",
    paddingHorizontal: 16,
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: "#E5E5EA",
  },
  headerTitle: {
    fontSize: 20,
    fontWeight: "600",
    color: "#1C1C1E",
  },
  tabsContainer: {
    backgroundColor: "white",
    flexDirection: "row",
    paddingHorizontal: 16,
    paddingVertical: 12,
  },
  tab: {
    flex: 1,
    alignItems: "center",
    paddingVertical: 8,
    marginHorizontal: 4,
    borderRadius: 8,
  },
  activeTab: {
    backgroundColor: "#007AFF",
  },
  tabText: {
    fontSize: 14,
    fontWeight: "500",
    color: "#8E8E93",
  },
  activeTabText: {
    color: "white",
  },
  listContainer: {
    paddingVertical: 8,
  },
});
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
<button> elements <TouchableOpacity>
Array mapping in JSX FlatList for performance
CSS classes for active Style array conditionals
Filter in render useMemo for performance
No count in tabs (common) Count badges built in

Important Concepts:

  1. useMemo for filtering: Prevents recalculating filtered list on every render
  2. FlatList for sessions: Better performance than mapping, especially with many sessions
  3. Tab counts: Show number of sessions in each status at a glance
  4. Type-safe tabs: TabType ensures you only use valid status values
  5. Sorting logic: Different sort order for upcoming (soonest first) vs completed (recent first)

Takeaway: Tab interfaces in React Native use the same state management as web but require more explicit styling. FlatList + useMemo is the performance-optimized pattern for filtered lists.


Step 4: Building Session Cards with Progress Bars

React (Web) - Card Component:

// SessionCard.js
function SessionCard({ session }) {
  const progress = (session.completedExercises / session.totalExercises) * 100;

  return (
    <div className="session-card" onClick={() => onSelect(session)}>
      <div className="date">{formatDate(session.date)}</div>
      <div className="info">
        <span className="type">{session.type}</span>
        <span className={`status ${session.status}`}>{session.status}</span>
        <h3>{session.consultant}</h3>
        <p>{session.time}</p>
        {session.status === "completed" && (
          <div className="progress">
            <div className="progress-bar">
              <div
                className="progress-fill"
                style={{ width: `${progress}%` }}
              />
            </div>
            <span>
              {session.completedExercises}/{session.totalExercises}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
/* SessionCard.css */
.session-card {
  display: flex;
  background: white;
  border-radius: 12px;
  padding: 16px;
  margin: 8px 16px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  cursor: pointer;
}

.date {
  flex: 1;
  font-weight: 600;
}

.info {
  flex: 2;
}

.progress-bar {
  flex: 1;
  height: 4px;
  background: #e5e5ea;
  border-radius: 2px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: #34c759;
  transition: width 0.3s ease;
}
Enter fullscreen mode Exit fullscreen mode

React Native - SessionCard with Dynamic Progress:

// src/components/ui-molecules/SessionCard.tsx
import React from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Session } from "../../types/session";

interface SessionCardProps {
  session: Session;
  onPress: (session: Session) => void;
}

export default function SessionCard({ session, onPress }: SessionCardProps) {
  const formatDate = (dateString: string) => {
    const date = new Date(dateString);
    return date.toLocaleDateString("en-US", {
      day: "numeric",
      month: "long",
    });
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case "upcoming":
        return "#007AFF";
      case "completed":
        return "#34C759";
      case "cancelled":
        return "#FF3B30";
      default:
        return "#8E8E93";
    }
  };

  const getStatusText = (status: string) => {
    switch (status) {
      case "upcoming":
        return "Visit Booked";
      case "completed":
        return "Completed";
      case "cancelled":
        return "Cancelled";
      default:
        return status;
    }
  };

  const progressPercentage =
    session.totalExercises > 0
      ? (session.completedExercises / session.totalExercises) * 100
      : 0;

  return (
    <TouchableOpacity style={styles.card} onPress={() => onPress(session)}>
      <View style={styles.leftSection}>
        <Text style={styles.dateText}>{formatDate(session.date)}</Text>
      </View>

      <View style={styles.rightSection}>
        <View style={styles.headerRow}>
          <Text style={styles.sessionType}>{session.type}</Text>
          <Text
            style={[styles.status, { color: getStatusColor(session.status) }]}
          >
            {getStatusText(session.status)}
          </Text>
        </View>

        <Text style={styles.consultantName}>{session.consultant.name}</Text>
        <Text style={styles.time}>{session.time}</Text>

        {session.status === "completed" && session.totalExercises > 0 && (
          <View style={styles.progressSection}>
            <View style={styles.progressBar}>
              <View
                style={[
                  styles.progressFill,
                  { width: `${progressPercentage}%` },
                ]}
              />
            </View>
            <Text style={styles.progressText}>
              {session.completedExercises}/{session.totalExercises}
            </Text>
          </View>
        )}
      </View>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: "white",
    borderRadius: 12,
    padding: 16,
    marginHorizontal: 16,
    marginVertical: 8,
    flexDirection: "row",
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  leftSection: {
    flex: 1,
    justifyContent: "center",
  },
  dateText: {
    fontSize: 16,
    fontWeight: "600",
    color: "#1C1C1E",
  },
  rightSection: {
    flex: 2,
  },
  headerRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    alignItems: "center",
    marginBottom: 4,
  },
  sessionType: {
    fontSize: 14,
    fontWeight: "500",
    color: "#007AFF",
  },
  status: {
    fontSize: 12,
    fontWeight: "500",
  },
  consultantName: {
    fontSize: 16,
    fontWeight: "500",
    color: "#1C1C1E",
    marginBottom: 2,
  },
  time: {
    fontSize: 14,
    color: "#8E8E93",
    marginBottom: 8,
  },
  progressSection: {
    flexDirection: "row",
    alignItems: "center",
  },
  progressBar: {
    flex: 1,
    height: 4,
    backgroundColor: "#E5E5EA",
    borderRadius: 2,
    marginRight: 8,
  },
  progressFill: {
    height: "100%",
    backgroundColor: "#34C759",
    borderRadius: 2,
  },
  progressText: {
    fontSize: 12,
    color: "#8E8E93",
    fontWeight: "500",
  },
});
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
<div onClick> <TouchableOpacity onPress>
Percentage width in inline CSS Percentage width in style object
CSS transitions for animations No built-in transitions (use Animated)
cursor: pointer for clickable Touchable components handle feedback
Box shadow with CSS Both shadow* (iOS) and elevation (Android)

Important Concepts:

  1. Status color mapping: Function returns appropriate color for each status
  2. Conditional progress bar: Only show for completed sessions with exercises
  3. Date formatting: Use JavaScript's toLocaleDateString for consistent formatting
  4. Shadow on both platforms: iOS uses shadow* props, Android uses elevation
  5. Two-section layout: Date on left (flex: 1), info on right (flex: 2)

Takeaway: Progress bars in React Native use percentage widths just like web. The key is calculating the percentage and applying it as an inline style. Status badges benefit from color functions rather than CSS classes.


Step 5: Building the Session Details Modal

This was the most interesting part! Mobile modals are different from web modals.

React (Web) - Centered Modal:

// SessionModal.js
function SessionModal({ session, isOpen, onClose }) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="close-button" onClick={onClose}>
          ×
        </button>
        <h2>Session Details</h2>
        <div className="section">
          <h3>Consultant</h3>
          <p>{session.consultant.name}</p>
          <p>{session.consultant.specialty}</p>
        </div>
        <div className="section">
          <h3>
            Exercises ({session.completedExercises}/{session.totalExercises})
          </h3>
          {session.exercises.map((ex) => (
            <div key={ex.id} className="exercise-item">
              <span>{ex.name}</span>
              {ex.weight && <span>{ex.weight}</span>}
              {ex.sets && (
                <span>
                  {ex.sets} sets × {ex.reps} reps
                </span>
              )}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
/* SessionModal.css */
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  border-radius: 12px;
  padding: 24px;
  max-width: 600px;
  max-height: 80vh;
  overflow-y: auto;
}
Enter fullscreen mode Exit fullscreen mode

React Native - Bottom Sheet Modal:

// src/components/ui-molecules/AppointmentDetailsSheet.tsx
import React from "react";
import {
  View,
  Text,
  Modal,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  FlatList,
} from "react-native";
import { Session } from "../../types/session";

interface AppointmentDetailsSheetProps {
  visible: boolean;
  session: Session | null;
  onClose: () => void;
}

export default function AppointmentDetailsSheet({
  visible,
  session,
  onClose,
}: AppointmentDetailsSheetProps) {
  if (!session) return null;

  const formatDate = (dateString: string) => {
    const date = new Date(dateString);
    return date.toLocaleDateString("en-US", {
      day: "numeric",
      month: "long",
      year: "numeric",
    });
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case "upcoming":
        return "#007AFF";
      case "completed":
        return "#34C759";
      case "cancelled":
        return "#FF3B30";
      default:
        return "#8E8E93";
    }
  };

  const renderExercise = ({ item }: { item: any }) => (
    <View style={styles.exerciseItem}>
      <Text style={styles.exerciseName}>{item.name}</Text>
      <View style={styles.exerciseDetails}>
        {item.weight && (
          <Text style={styles.exerciseDetail}>{item.weight}</Text>
        )}
        {item.sets && item.reps && (
          <Text style={styles.exerciseDetail}>
            {item.sets} sets × {item.reps} reps
          </Text>
        )}
        {item.duration && (
          <Text style={styles.exerciseDetail}>{item.duration}</Text>
        )}
      </View>
    </View>
  );

  return (
    <Modal
      visible={visible}
      animationType="slide"
      presentationStyle="pageSheet"
      onRequestClose={onClose}
    >
      <View style={styles.container}>
        <View style={styles.header}>
          <TouchableOpacity onPress={onClose} style={styles.closeButton}>
            <Text style={styles.closeButtonText}></Text>
          </TouchableOpacity>
          <Text style={styles.headerTitle}>Session Details</Text>
          <View style={styles.headerSpacer} />
        </View>

        <ScrollView style={styles.content}>
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Consultant</Text>
            <Text style={styles.consultantName}>{session.consultant.name}</Text>
            <Text style={styles.consultantSpecialty}>
              {session.consultant.specialty}
            </Text>
          </View>

          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Session Info</Text>
            <View style={styles.infoRow}>
              <Text style={styles.infoLabel}>Date:</Text>
              <Text style={styles.infoValue}>{formatDate(session.date)}</Text>
            </View>
            <View style={styles.infoRow}>
              <Text style={styles.infoLabel}>Time:</Text>
              <Text style={styles.infoValue}>{session.time}</Text>
            </View>
            <View style={styles.infoRow}>
              <Text style={styles.infoLabel}>Duration:</Text>
              <Text style={styles.infoValue}>{session.duration}</Text>
            </View>
            <View style={styles.infoRow}>
              <Text style={styles.infoLabel}>Type:</Text>
              <Text style={styles.infoValue}>{session.type}</Text>
            </View>
            <View style={styles.infoRow}>
              <Text style={styles.infoLabel}>Status:</Text>
              <Text
                style={[
                  styles.infoValue,
                  { color: getStatusColor(session.status) },
                ]}
              >
                {session.status.charAt(0).toUpperCase() +
                  session.status.slice(1)}
              </Text>
            </View>
          </View>

          {session.center && (
            <View style={styles.section}>
              <Text style={styles.sectionTitle}>Center</Text>
              <Text style={styles.centerName}>{session.center.name}</Text>
            </View>
          )}

          <View style={styles.section}>
            <Text style={styles.sectionTitle}>
              Exercises ({session.completedExercises}/{session.totalExercises})
            </Text>
            <FlatList
              data={session.exercises}
              keyExtractor={(item) => item.id}
              renderItem={renderExercise}
              scrollEnabled={false}
            />
          </View>

          {session.notes && (
            <View style={styles.section}>
              <Text style={styles.sectionTitle}>Notes</Text>
              <Text style={styles.notes}>{session.notes}</Text>
            </View>
          )}
        </ScrollView>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "white",
  },
  header: {
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: 16,
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: "#E5E5EA",
  },
  closeButton: {
    width: 32,
    height: 32,
    borderRadius: 16,
    backgroundColor: "#F2F2F7",
    alignItems: "center",
    justifyContent: "center",
  },
  closeButtonText: {
    fontSize: 18,
    color: "#007AFF",
    fontWeight: "bold",
  },
  headerTitle: {
    flex: 1,
    textAlign: "center",
    fontSize: 18,
    fontWeight: "600",
    color: "#1C1C1E",
  },
  headerSpacer: {
    width: 32,
  },
  content: {
    flex: 1,
    padding: 16,
  },
  section: {
    marginBottom: 24,
  },
  sectionTitle: {
    fontSize: 16,
    fontWeight: "600",
    color: "#1C1C1E",
    marginBottom: 8,
  },
  consultantName: {
    fontSize: 18,
    fontWeight: "500",
    color: "#1C1C1E",
    marginBottom: 4,
  },
  consultantSpecialty: {
    fontSize: 14,
    color: "#8E8E93",
  },
  centerName: {
    fontSize: 16,
    color: "#1C1C1E",
  },
  infoRow: {
    flexDirection: "row",
    marginBottom: 8,
  },
  infoLabel: {
    fontSize: 14,
    color: "#8E8E93",
    width: 80,
  },
  infoValue: {
    fontSize: 14,
    color: "#1C1C1E",
    flex: 1,
  },
  exerciseItem: {
    backgroundColor: "#F2F2F7",
    padding: 12,
    borderRadius: 8,
    marginBottom: 8,
  },
  exerciseName: {
    fontSize: 16,
    fontWeight: "500",
    color: "#1C1C1E",
    marginBottom: 4,
  },
  exerciseDetails: {
    flexDirection: "row",
    flexWrap: "wrap",
  },
  exerciseDetail: {
    fontSize: 12,
    color: "#8E8E93",
    marginRight: 12,
    marginBottom: 2,
  },
  notes: {
    fontSize: 14,
    color: "#1C1C1E",
    lineHeight: 20,
  },
});
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
Centered with overlay Full screen "pageSheet" style
position: fixed Modal component
Overlay click to close onRequestClose for back button
CSS z-index Built-in z-index handling
Scroll with CSS ScrollView component
Animation with CSS animationType="slide" prop

Important Concepts:

  1. presentationStyle="pageSheet": iOS-style modal that slides up from bottom
  2. animationType="slide": Smooth slide-up animation
  3. onRequestClose: Required for Android back button handling
  4. Header with spacer: Centered title with close button on left
  5. ScrollView for content: Allows scrolling long session details
  6. FlatList with scrollEnabled={false}: Exercises list that doesn't scroll independently
  7. Section-based layout: Clear visual hierarchy with section titles

Takeaway: React Native modals feel more native than web modals. The pageSheet presentation style is familiar to mobile users, and the slide animation provides context. Always handle onRequestClose for proper Android back button support.


Step 6: Adding Search Functionality

React (Web) - Search with Filter:

// Timeline.js
function Timeline() {
  const [search, setSearch] = useState("");
  const [activeTab, setActiveTab] = useState("upcoming");

  const filteredSessions = sessions
    .filter((s) => s.status === activeTab)
    .filter(
      (s) =>
        s.consultant.toLowerCase().includes(search.toLowerCase()) ||
        s.type.toLowerCase().includes(search.toLowerCase())
    );

  return (
    <div>
      <input
        type="text"
        placeholder="Search sessions..."
        value={search}
        onChange={(e) => setSearch(e.target.value)}
      />
      {/* Render filtered sessions */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

React Native - Search with useMemo:

// Inside TimelineTab.tsx
const [searchQuery, setSearchQuery] = useState("");

const filteredSessions = useMemo(() => {
  let sessions = mockSessions.filter((session) => session.status === activeTab);

  if (searchQuery.trim()) {
    const query = searchQuery.toLowerCase();
    sessions = sessions.filter(
      (session) =>
        session.consultant.name.toLowerCase().includes(query) ||
        session.type.toLowerCase().includes(query) ||
        session.date.includes(query)
    );
  }

  // Sort by date
  return sessions.sort((a, b) => {
    const dateA = new Date(a.date).getTime();
    const dateB = new Date(b.date).getTime();
    return activeTab === "upcoming" ? dateA - dateB : dateB - dateA;
  });
}, [activeTab, searchQuery]);

return (
  <View>
    <View style={styles.searchContainer}>
      <TextInput
        style={styles.searchInput}
        placeholder="Search by consultant, type, or date..."
        value={searchQuery}
        onChangeText={setSearchQuery}
      />
    </View>

    <FlatList
      data={filteredSessions}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <SessionCard session={item} />}
      ListEmptyComponent={
        <View style={styles.emptyContainer}>
          <Text style={styles.emptyText}>
            No {activeTab} appointments found
          </Text>
          {searchQuery && (
            <Text style={styles.emptySubtext}>
              Try adjusting your search terms
            </Text>
          )}
        </View>
      }
    />
  </View>
);
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
Filter in render useMemo to optimize filtering
<input type="text"> <TextInput>
No empty state (usually) ListEmptyComponent for better UX
Simple {list.map()} FlatList with performance benefits

Important Concepts:

  1. useMemo with multiple dependencies: Recalculates when activeTab OR searchQuery changes
  2. Trim search query: Ignore whitespace-only searches
  3. Multiple search fields: Search by consultant name, type, or date
  4. Conditional sorting: Different sort order for upcoming vs completed
  5. Empty state with context: Show different messages for no results vs no appointments
  6. Search hint in placeholder: Tells user what they can search for

Takeaway: Search in React Native uses the same filtering logic as web, but useMemo is more important for performance. Always provide helpful empty states, especially when search returns no results.


Step 7: Managing Modal State

React (Web) - Simple Modal State:

// Timeline.js
function Timeline() {
  const [selectedSession, setSelectedSession] = useState(null);

  return (
    <div>
      {sessions.map((session) => (
        <SessionCard
          key={session.id}
          session={session}
          onClick={() => setSelectedSession(session)}
        />
      ))}
      <SessionModal
        session={selectedSession}
        isOpen={!!selectedSession}
        onClose={() => setSelectedSession(null)}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

React Native - Separate Modal Visibility State:

// Inside TimelineTab.tsx
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [showDetails, setShowDetails] = useState(false);

const handleSessionPress = (session: Session) => {
  setSelectedSession(session);
  setShowDetails(true);
};

const handleCloseDetails = () => {
  setShowDetails(false);
  setSelectedSession(null);
};

return (
  <View>
    <FlatList
      data={filteredSessions}
      renderItem={({ item }) => (
        <SessionCard session={item} onPress={handleSessionPress} />
      )}
    />

    <AppointmentDetailsSheet
      visible={showDetails}
      session={selectedSession}
      onClose={handleCloseDetails}
    />
  </View>
);
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
Single state (selected item) Two states (selected item + visibility)
Boolean check for visibility Explicit visible boolean
onClick prop onPress prop

Why Separate States?

  1. Animation control: Need visibility state for slide animations
  2. Cleanup timing: Can hide modal before clearing selected session
  3. Type safety: Explicit boolean is clearer than !!object
  4. Flexibility: Can show modal with previous data while loading new data

Takeaway: React Native modals benefit from explicit visibility state rather than deriving it from data presence. This gives you more control over animations and cleanup.


Step 8: Integrating with Tab Navigation

React (Web) - Route-Based:

// App.js
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/timeline" element={<Timeline />} />
  <Route path="/profile" element={<Profile />} />
</Routes>
Enter fullscreen mode Exit fullscreen mode

React Native - Tab Navigator:

// src/navigation/MainTabNavigator.tsx
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import HomeStackNavigator from "./HomeStackNavigator";
import TimelineTab from "../components/screens/TimelineTab";
import ProfileTab from "../components/screens/ProfileTab";

const Tab = createBottomTabNavigator();

export default function MainTabNavigator() {
  return (
    <Tab.Navigator
      screenOptions={{
        headerShown: false,
        tabBarActiveTintColor: "#007AFF",
        tabBarInactiveTintColor: "#8E8E93",
      }}
    >
      <Tab.Screen
        name="Home"
        component={HomeStackNavigator}
        options={{ tabBarLabel: "Home" }}
      />
      <Tab.Screen
        name="Timeline"
        component={TimelineTab}
        options={{ tabBarLabel: "Timeline" }}
      />
      <Tab.Screen
        name="Profile"
        component={ProfileTab}
        options={{ tabBarLabel: "Profile" }}
      />
    </Tab.Navigator>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Differences:

React Web React Native
URL-based navigation Component-based navigation
Browser back button Native back gesture
No built-in tabs createBottomTabNavigator
Custom tab UI required Native tab bar included

Important Concepts:

  1. Bottom tabs are native: iOS and Android styles automatically
  2. Tab bar customization: Colors, labels, icons all configurable
  3. headerShown: false: Hide duplicate headers when needed
  4. Nested navigators: Home is a stack, Timeline is single screen
  5. Persistent state: Tabs keep their state when switching

Takeaway: React Navigation's bottom tabs feel native on both platforms without extra work. The tab bar automatically matches iOS/Android conventions.


Problems I Faced & How I Solved Them

Problem 1: FlatList Not Updating After Search

Issue: Typing in search input didn't update the session list.

Root Cause: Forgot to include searchQuery in useMemo dependencies.

Solution:

// ❌ BAD: Missing searchQuery dependency
const filteredSessions = useMemo(() => {
  // ... filtering logic
}, [activeTab]); // Missing searchQuery!

// ✅ GOOD: Include all dependencies
const filteredSessions = useMemo(() => {
  // ... filtering logic
}, [activeTab, searchQuery]);
Enter fullscreen mode Exit fullscreen mode

Takeaway: Always include all state variables used inside useMemo in the dependencies array. React's ESLint plugin will warn you about this.


Problem 2: Modal Showing Old Data When Opening

Issue: When opening session details, sometimes old session data flashed briefly.

Root Cause: Modal was visible while selectedSession was being updated.

Solution: Separate visibility from data state:

// ✅ GOOD: Set data first, then show modal
const handleSessionPress = (session: Session) => {
  setSelectedSession(session); // Update data
  setShowDetails(true); // Then show modal
};

const handleCloseDetails = () => {
  setShowDetails(false); // Hide modal first
  setSelectedSession(null); // Then clear data after animation
};
Enter fullscreen mode Exit fullscreen mode

Takeaway: Order matters when updating modal state. Update data before showing, clear data after hiding.


Problem 3: Progress Bar Not Showing Percentage Correctly

Issue: Progress bars showed 0% or 100% only, no in-between values.

Root Cause: Width calculation returned integer instead of percentage string.

Solution:

// ❌ BAD: Returns number
const progressPercentage = (completed / total) * 100;
// Results in: width: 50 (invalid, needs units)

// ✅ GOOD: Returns string with %
const progressPercentage =
  session.totalExercises > 0
    ? (session.completedExercises / session.totalExercises) * 100
    : 0;

// Then use in style:
<View style={[styles.progressFill, { width: `${progressPercentage}%` }]} />;
Enter fullscreen mode Exit fullscreen mode

Takeaway: React Native requires width percentages as strings with % symbol. Always use template literals for percentage widths.


Problem 4: ScrollView Inside FlatList Warning

Issue: Console warning about nested virtualized lists.

Root Cause: Used FlatList for exercises inside ScrollView in modal.

Solution: Set scrollEnabled={false} on nested FlatList:

// Inside AppointmentDetailsSheet
<ScrollView style={styles.content}>
  {/* Other content */}
  <FlatList
    data={session.exercises}
    renderItem={renderExercise}
    scrollEnabled={false} // ✅ Disable scroll for nested list
  />
</ScrollView>
Enter fullscreen mode Exit fullscreen mode

Takeaway: When nesting FlatList inside ScrollView, disable scrolling on the FlatList. The outer ScrollView handles all scrolling.


Problem 5: Tab Counts Not Updating

Issue: Tab badges showing wrong counts when sessions added/removed.

Root Cause: Counts calculated in component render without dependency on data changes.

Solution: Calculate counts in render, not in state:

// ✅ GOOD: Recalculates every render (cheap operation)
const tabs = [
  {
    key: "upcoming",
    label: "Upcoming",
    count: mockSessions.filter((s) => s.status === "upcoming").length,
  },
  // ... other tabs
];
Enter fullscreen mode Exit fullscreen mode

Alternative: Use useMemo if calculation is expensive:

const tabCounts = useMemo(
  () => ({
    upcoming: mockSessions.filter((s) => s.status === "upcoming").length,
    completed: mockSessions.filter((s) => s.status === "completed").length,
    cancelled: mockSessions.filter((s) => s.status === "cancelled").length,
  }),
  [mockSessions]
);
Enter fullscreen mode Exit fullscreen mode

Takeaway: For simple counts from arrays, recalculating in render is fine. Use useMemo only when calculation is expensive.


Problem 6: Empty State Not Showing

Issue: When search returned no results, saw blank screen instead of message.

Root Cause: Forgot to add ListEmptyComponent to FlatList.

Solution:

<FlatList
  data={filteredSessions}
  renderItem={({ item }) => <SessionCard session={item} />}
  ListEmptyComponent={
    <View style={styles.emptyContainer}>
      <Text style={styles.emptyText}>No {activeTab} appointments found</Text>
      {searchQuery && (
        <Text style={styles.emptySubtext}>Try adjusting your search terms</Text>
      )}
    </View>
  }
/>
Enter fullscreen mode Exit fullscreen mode

Takeaway: Always add ListEmptyComponent to FlatLists that can be empty. It provides essential UX feedback when lists are empty.


What I Tested

Here's my testing checklist for Phase 5:

✅ Tab Navigation

  • [x] Three tabs visible (Upcoming, Completed, Cancelled)
  • [x] Tab counts show correct numbers
  • [x] Active tab highlighted correctly
  • [x] Tapping tab switches content
  • [x] Tab state persists when leaving and returning

✅ Session List

  • [x] Sessions display in correct tab based on status
  • [x] Upcoming sessions sorted by date (soonest first)
  • [x] Completed sessions sorted by date (most recent first)
  • [x] Session cards show all information correctly
  • [x] Progress bars visible only for completed sessions
  • [x] Progress percentages calculate correctly (0%, 50%, 100%, etc.)
  • [x] Status badges colored appropriately

✅ Search Functionality

  • [x] Search input accepts text
  • [x] Search filters by consultant name
  • [x] Search filters by session type (In-Clinic, Online)
  • [x] Search filters by date
  • [x] Search is case-insensitive
  • [x] Empty search shows all sessions
  • [x] Search works across all tabs
  • [x] Search with no results shows empty state

✅ Empty States

  • [x] Empty state shows when tab has no sessions
  • [x] Empty state shows when search returns no results
  • [x] Empty state messages are contextual and helpful
  • [x] Empty state styling is clear and centered

✅ Session Details Modal

  • [x] Modal opens when tapping session card
  • [x] Modal slides up from bottom smoothly
  • [x] Close button works
  • [x] Android back button closes modal
  • [x] All session information displays correctly
  • [x] Consultant name and specialty visible
  • [x] Session info (date, time, duration, type, status) complete
  • [x] Center information shows for in-clinic sessions
  • [x] Exercise list renders correctly
  • [x] Exercise details (weight, sets, reps, duration) format properly
  • [x] Notes section appears only when notes exist
  • [x] Progress count matches session data
  • [x] Modal content scrolls for long sessions

✅ Date and Time Formatting

  • [x] Dates format correctly ("15 December")
  • [x] Times display in 12-hour format
  • [x] Month names are spelled out
  • [x] Years show in full session details

✅ Status Colors and Text

  • [x] Upcoming shows blue (#007AFF)
  • [x] Completed shows green (#34C759)
  • [x] Cancelled shows red (#FF3B30)
  • [x] Status text transforms correctly ("upcoming" → "Visit Booked")

✅ Performance

  • [x] List scrolls smoothly with all sessions
  • [x] Search filtering happens instantly
  • [x] Tab switching is immediate
  • [x] No lag when opening/closing modal
  • [x] No console warnings or errors

✅ Edge Cases

  • [x] Sessions with 0 exercises don't crash
  • [x] Sessions without center (online) display correctly
  • [x] Sessions without notes don't show notes section
  • [x] Empty tabs show appropriate message
  • [x] Search handles special characters
  • [x] Very long consultant names don't break layout
  • [x] Many exercises in modal scroll properly

What I Learned: Key Takeaways

  1. useMemo is Essential: For lists with filtering and sorting, useMemo prevents expensive recalculations. It's more important in React Native than web.

  2. FlatList Performance: Use FlatList for any list that might grow. It virtualizes items for better performance than mapping arrays.

  3. Modal Patterns Differ: Mobile modals slide up from bottom (pageSheet) rather than centering with overlay. This feels more natural on touch devices.

  4. Progress Bars are Simple: Calculate percentage and use as width. No need for libraries—percentage widths work great.

  5. Empty States Matter: Mobile apps need clear empty states. ListEmptyComponent makes this easy and is essential for good UX.

  6. Search is Instant: Real-time filtering with useMemo provides instant search results without debouncing or API calls.

  7. Tab Badges Help: Showing counts in tabs gives users context about data distribution before switching tabs.

  8. State Management for Modals: Separate visibility state from data state gives better control over animations and cleanup.

  9. Date Formatting: JavaScript's built-in toLocaleDateString works perfectly in React Native for formatting dates.

  10. Nested FlatLists: When nesting FlatList in ScrollView, disable scrolling on inner FlatList to prevent virtualization warnings.


Common Questions (If You're Coming from React)

Q: Should I use FlatList or map() for lists?

A: FlatList for lists that might grow beyond 50 items or come from APIs. map() for small, fixed lists (< 20 items). FlatList virtualizes; map() renders everything.

Q: How do I handle pagination with FlatList?

A: FlatList has built-in onEndReached prop for infinite scrolling. I'll cover this when we connect to real APIs in Phase 8.

Q: Can I use libraries for modals?

A: Yes! react-native-modal and @gorhom/bottom-sheet are excellent. I built custom ones to learn, but libraries offer more features.

Q: What about pull-to-refresh?

A: FlatList has built-in refreshControl prop. Easy to add with RefreshControl component. Coming in Phase 8 when we add API calls.

Q: How do I style the tab bar?

A: Use screenOptions in Tab.Navigator. You can customize colors, icons, labels, and even create fully custom tab bars.

Q: Should I use useMemo for everything?

A: No! Only for expensive calculations (filtering large arrays, complex computations). Don't optimize prematurely. Profile first.

Q: How do I handle keyboard with search?

A: TextInput handles keyboard automatically. For advanced control, use KeyboardAvoidingView. Will cover more in forms (Phase 7).

Q: Can I use React Hook Form or Formik for search?

A: Overkill for simple search. Plain useState is perfect. Save form libraries for complex multi-field forms with validation.


Resources That Helped Me


Code Repository

All the code from Phase 5 is available on GitHub:


Final Thoughts

Phase 5 was about mastering lists, tabs, and modals—the core building blocks of most mobile apps. Building a timeline view taught me how to handle complex data filtering, create smooth user experiences with tabs and search, and build modal interfaces that feel native.

The biggest learning was understanding performance optimization in React Native. useMemo isn't just an optimization—it's essential for smooth filtering and search. FlatList isn't just a fancy list component—it's the difference between a smooth app and a laggy one when your list grows.

What surprised me most was how much better the mobile UX patterns felt compared to web. Tabs at the bottom are more accessible than dropdowns. Bottom sheets feel more natural than centered modals. Progress bars give instant visual feedback. These aren't just style choices—they're fundamental to mobile UX.

Next up: Building the Support tab (Phase 6) with chat interfaces and video consultation requests. This will introduce more complex list patterns and real-time communication patterns.

If you're a React developer, the transition to building timeline and list views in React Native will feel familiar. The filtering logic is identical. The challenges are performance (use useMemo and FlatList) and UX patterns (tabs instead of dropdowns, sheets instead of modals). But once you learn these patterns, you'll find mobile UX more intuitive than web.

Top comments (0)