WellAlly Logo
WellAlly康心伴
Development

Building a Performant, Empathetic Mood Tracking App in React

Learn how to build a mood tracking application that balances technical performance with emotional sensitivity. This tutorial covers accessibility-first design, optimistic UI updates, progressive data loading, and strategies for creating an interface that supports rather than overwhelms users.

W
WellAlly Dev Team
2026-04-06
12 min read

Building a Performant, Empathetic Mood Tracking App in React

Mood tracking applications occupy a unique intersection of software engineering and human psychology. Users open these apps during moments of emotional vulnerability, whether to record joy, frustration, anxiety, or sadness. The application must respond quickly, look calming, and never make the user feel judged or overwhelmed by the process of self-reporting their emotional state.

This tutorial builds a mood tracking app in React that prioritizes both technical performance and empathetic design. Every decision, from state management to animation choices, serves the dual goal of being fast to render and gentle to experience.

Core Design Principles

Three principles guide the entire implementation:

Speed as empathy: A slow app adds frustration to someone already dealing with difficult emotions. Every interaction should complete in under 100ms of perceived latency.

Minimal cognitive load: The user should never have to think about how to use the app. The interface should feel intuitive even during moments of high emotional distress.

Non-judgmental presentation: Data visualizations should inform without prescribing. Trends are shown as observations, not verdicts.

Technical Architecture

The app uses a deliberately simple stack to avoid unnecessary complexity:

  • React 19 with hooks for UI and state
  • Zustand for lightweight global state management
  • IndexedDB (via idb) for local-only, privacy-preserving storage
  • CSS custom properties for theming with smooth transitions
  • The Web Animations API for performant, accessible animations

No backend is needed. All data lives in the browser, reinforcing the privacy-first approach.

Setting Up the Project

code
npm create vite@latest mood-tracker -- --template react-ts
cd mood-tracker
npm install zustand idb date-fns
npm install -D @types/node
Code collapsed

Data Model Design

The mood data model follows established psychological assessment patterns without being rigid. It supports both quick check-ins and detailed journaling.

Create src/types/mood.ts:

code
export type MoodLevel = 1 | 2 | 3 | 4 | 5;

export interface MoodEntry {
  id: string;
  timestamp: number;
  mood: MoodLevel;
  energy: MoodLevel;
  anxiety: MoodLevel;
  emotions: string[];
  activities: string[];
  notes: string;
  sleepQuality: MoodLevel | null;
  socialInteraction: 'alone' | 'small-group' | 'large-group' | null;
}

export interface MoodTrend {
  period: 'week' | 'month' | 'quarter';
  averageMood: number;
  averageEnergy: number;
  averageAnxiety: number;
  dominantEmotions: Array<{ emotion: string; count: number }>;
  moodVariability: number;
  totalEntries: number;
}

export const MOOD_LABELS: Record<MoodLevel, { label: string; color: string; description: string }> = {
  1: { label: 'Very Low', color: '#6366f1', description: 'Feeling very down or distressed' },
  2: { label: 'Low', color: '#818cf8', description: 'Below average mood' },
  3: { label: 'Neutral', color: '#a3a3a3', description: 'Neither good nor bad' },
  4: { label: 'Good', color: '#4ade80', description: 'Feeling positive and capable' },
  5: { label: 'Great', color: '#22c55e', description: 'Feeling excellent and energized' },
};

export const EMOTION_OPTIONS = [
  'Happy', 'Calm', 'Grateful', 'Hopeful', 'Content',
  'Anxious', 'Sad', 'Angry', 'Frustrated', 'Overwhelmed',
  'Lonely', 'Tired', 'Stressed', 'Confused', 'Numb',
  'Excited', 'Proud', 'Relieved', 'Curious', 'Peaceful',
];

export const ACTIVITY_OPTIONS = [
  'Exercise', 'Work', 'Socializing', 'Reading', 'Meditation',
  'Creative work', 'Nature', 'Cooking', 'Gaming', 'Rest',
  'Learning', 'Volunteering', 'Shopping', 'Cleaning', 'Travel',
];
Code collapsed

Local-First Storage with IndexedDB

IndexedDB provides a robust, schema-capable local database that persists across sessions without any server dependency.

Create src/storage/moodStorage.ts:

code
import { openDB, DBSchema, IDBPDatabase } from 'idb';
import { MoodEntry, MoodTrend } from '../types/mood';

interface MoodTrackerDB extends DBSchema {
  entries: {
    key: string;
    value: MoodEntry;
    indexes: {
      'by-timestamp': number;
      'by-mood': number;
    };
  };
}

let dbInstance: IDBPDatabase<MoodTrackerDB> | null = null;

async function getDb(): Promise<IDBPDatabase<MoodTrackerDB>> {
  if (dbInstance) return dbInstance;

  dbInstance = await openDB<MoodTrackerDB>('mood-tracker', 1, {
    upgrade(db) {
      const store = db.createObjectStore('entries', { keyPath: 'id' });
      store.createIndex('by-timestamp', 'timestamp');
      store.createIndex('by-mood', 'mood');
    },
  });

  return dbInstance;
}

export const moodStorage = {
  async addEntry(entry: MoodEntry): Promise<void> {
    const db = await getDb();
    await db.put('entries', entry);
  },

  async getEntries(startDate: number, endDate: number): Promise<MoodEntry[]> {
    const db = await getDb();
    const range = IDBKeyRange.bound(startDate, endDate);
    const entries = await db.getAllFromIndex('entries', 'by-timestamp', range);
    return entries.reverse(); // Most recent first
  },

  async getEntry(id: string): Promise<MoodEntry | undefined> {
    const db = await getDb();
    return await db.get('entries', id);
  },

  async updateEntry(entry: MoodEntry): Promise<void> {
    const db = await getDb();
    await db.put('entries', entry);
  },

  async deleteEntry(id: string): Promise<void> {
    const db = await getDb();
    await db.delete('entries', id);
  },

  async getAllEntries(): Promise<MoodEntry[]> {
    const db = await getDb();
    const entries = await db.getAllFromIndex('entries', 'by-timestamp');
    return entries.reverse();
  },

  async getRecentEntries(count: number): Promise<MoodEntry[]> {
    const db = await getDb();
    const entries = await db.getAllFromIndex('entries', 'by-timestamp');
    return entries.reverse().slice(0, count);
  },

  async computeTrend(period: 'week' | 'month' | 'quarter'): Promise<MoodTrend | null> {
    const now = Date.now();
    const periodMs = period === 'week' ? 7 * 86400000
      : period === 'month' ? 30 * 86400000
      : 90 * 86400000;
    const entries = await this.getEntries(now - periodMs, now);

    if (entries.length === 0) return null;

    const avgMood = entries.reduce((s, e) => s + e.mood, 0) / entries.length;
    const avgEnergy = entries.reduce((s, e) => s + e.energy, 0) / entries.length;
    const avgAnxiety = entries.reduce((s, e) => s + e.anxiety, 0) / entries.length;

    const emotionCounts = new Map<string, number>();
    entries.forEach((e) => {
      e.emotions.forEach((emotion) => {
        emotionCounts.set(emotion, (emotionCounts.get(emotion) || 0) + 1);
      });
    });

    const dominantEmotions = Array.from(emotionCounts.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, 5)
      .map(([emotion, count]) => ({ emotion, count }));

    const moodValues = entries.map((e) => e.mood);
    const moodMean = avgMood;
    const moodVariance = moodValues.reduce((s, v) => s + Math.pow(v - moodMean, 2), 0) / moodValues.length;
    const moodVariability = Math.sqrt(moodVariance);

    return {
      period,
      averageMood: Math.round(avgMood * 100) / 100,
      averageEnergy: Math.round(avgEnergy * 100) / 100,
      averageAnxiety: Math.round(avgAnxiety * 100) / 100,
      dominantEmotions,
      moodVariability: Math.round(moodVariability * 100) / 100,
      totalEntries: entries.length,
    };
  },
};
Code collapsed

State Management with Zustand

Zustand provides a minimal, performant state management solution that avoids the boilerplate of Redux while supporting optimistic updates.

Create src/store/moodStore.ts:

code
import { create } from 'zustand';
import { MoodEntry, MoodTrend } from '../types/mood';
import { moodStorage } from '../storage/moodStorage';

interface MoodState {
  entries: MoodEntry[];
  currentTrend: MoodTrend | null;
  isLoading: boolean;
  isSubmitting: boolean;
  error: string | null;

  // Actions
  loadRecentEntries: (count?: number) => Promise<void>;
  loadTrend: (period: 'week' | 'month' | 'quarter') => Promise<void>;
  addEntry: (entry: MoodEntry) => Promise<void>;
  deleteEntry: (id: string) => Promise<void>;
}

export const useMoodStore = create<MoodState>((set, get) => ({
  entries: [],
  currentTrend: null,
  isLoading: false,
  isSubmitting: false,
  error: null,

  loadRecentEntries: async (count = 30) => {
    set({ isLoading: true, error: null });
    try {
      const entries = await moodStorage.getRecentEntries(count);
      set({ entries, isLoading: false });
    } catch (error) {
      set({ error: 'Failed to load entries', isLoading: false });
    }
  },

  loadTrend: async (period) => {
    try {
      const trend = await moodStorage.computeTrend(period);
      set({ currentTrend: trend });
    } catch (error) {
      console.error('Failed to compute trend:', error);
    }
  },

  addEntry: async (entry) => {
    // Optimistic update: add to state immediately
    set((state) => ({
      entries: [entry, ...state.entries],
      isSubmitting: true,
    }));

    try {
      await moodStorage.addEntry(entry);
      set({ isSubmitting: false });
    } catch (error) {
      // Rollback on failure
      set((state) => ({
        entries: state.entries.filter((e) => e.id !== entry.id),
        isSubmitting: false,
        error: 'Failed to save entry. It has been kept locally.',
      }));
    }
  },

  deleteEntry: async (id) => {
    // Optimistic update
    const previous = get().entries;
    set((state) => ({
      entries: state.entries.filter((e) => e.id !== id),
    }));

    try {
      await moodStorage.deleteEntry(id);
    } catch (error) {
      // Rollback
      set({ entries: previous, error: 'Failed to delete entry.' });
    }
  },
}));
Code collapsed

The Mood Entry Component

The primary interaction point. This component must feel effortless even during high emotional load.

Create src/components/MoodCheckIn.tsx:

code
import React, { useState, useCallback } from 'react';
import { MOOD_LABELS, EMOTION_OPTIONS, ACTIVITY_OPTIONS, MoodLevel, MoodEntry } from '../types/mood';
import { useMoodStore } from '../store/moodStore';
import './MoodCheckIn.css';

export default function MoodCheckIn() {
  const addEntry = useMoodStore((s) => s.addEntry);
  const isSubmitting = useMoodStore((s) => s.isSubmitting);

  const [step, setStep] = useState<1 | 2 | 3>(1);
  const [mood, setMood] = useState<MoodLevel | null>(null);
  const [energy, setEnergy] = useState<MoodLevel | null>(null);
  const [anxiety, setAnxiety] = useState<MoodLevel | null>(null);
  const [selectedEmotions, setSelectedEmotions] = useState<Set<string>>(new Set());
  const [selectedActivities, setSelectedActivities] = useState<Set<string>>(new Set());
  const [notes, setNotes] = useState('');

  const toggleSet = useCallback((set: Set<string>, item: string): Set<string> => {
    const next = new Set(set);
    if (next.has(item)) next.delete(item);
    else next.add(item);
    return next;
  }, []);

  const handleSubmit = useCallback(async () => {
    if (!mood || !energy || !anxiety) return;

    const entry: MoodEntry = {
      id: crypto.randomUUID(),
      timestamp: Date.now(),
      mood,
      energy,
      anxiety,
      emotions: Array.from(selectedEmotions),
      activities: Array.from(selectedActivities),
      notes,
      sleepQuality: null,
      socialInteraction: null,
    };

    await addEntry(entry);

    // Reset form with gentle animation
    setMood(null);
    setEnergy(null);
    setAnxiety(null);
    setSelectedEmotions(new Set());
    setSelectedActivities(new Set());
    setNotes('');
    setStep(1);
  }, [mood, energy, anxiety, selectedEmotions, selectedActivities, notes, addEntry]);

  return (
    <div className="mood-check-in" role="form" aria-label="Mood check-in">
      {step === 1 && (
        <div className="step step-mood" role="group" aria-label="Rate your mood">
          <h2 className="step-title">How are you feeling right now?</h2>
          <div className="mood-scale">
            {([1, 2, 3, 4, 5] as MoodLevel[]).map((level) => (
              <button
                key={level}
                className={`mood-button ${mood === level ? 'mood-button-selected' : ''}`}
                style={{
                  '--mood-color': MOOD_LABELS[level].color,
                } as React.CSSProperties}
                onClick={() => setMood(level)}
                aria-pressed={mood === level}
                aria-label={`${MOOD_LABELS[level].label}: ${MOOD_LABELS[level].description}`}
              >
                <span className="mood-number">{level}</span>
                <span className="mood-label">{MOOD_LABELS[level].label}</span>
              </button>
            ))}
          </div>

          <div className="dual-slider">
            <div className="slider-group">
              <label htmlFor="energy-slider" className="slider-label">Energy level</label>
              <input
                id="energy-slider"
                type="range"
                min={1}
                max={5}
                step={1}
                value={energy || 3}
                onChange={(e) => setEnergy(Number(e.target.value) as MoodLevel)}
                aria-valuenow={energy || 3}
                aria-valuemin={1}
                aria-valuemax={5}
                aria-valuetext={energy ? MOOD_LABELS[energy].label : 'Neutral'}
              />
            </div>
            <div className="slider-group">
              <label htmlFor="anxiety-slider" className="slider-label">Anxiety level</label>
              <input
                id="anxiety-slider"
                type="range"
                min={1}
                max={5}
                step={1}
                value={anxiety || 3}
                onChange={(e) => setAnxiety(Number(e.target.value) as MoodLevel)}
                aria-valuenow={anxiety || 3}
                aria-valuemin={1}
                aria-valuemax={5}
                aria-valuetext={anxiety ? MOOD_LABELS[anxiety].label : 'Neutral'}
              />
            </div>
          </div>

          <button
            className="next-button"
            onClick={() => setStep(2)}
            disabled={!mood}
            aria-label="Continue to emotions"
          >
            Continue
          </button>
        </div>
      )}

      {step === 2 && (
        <div className="step step-emotions" role="group" aria-label="Select your emotions">
          <h2 className="step-title">What emotions are you experiencing?</h2>
          <div className="chip-grid" role="group" aria-label="Emotion options">
            {EMOTION_OPTIONS.map((emotion) => (
              <button
                key={emotion}
                className={`chip ${selectedEmotions.has(emotion) ? 'chip-selected' : ''}`}
                onClick={() => setSelectedEmotions((prev) => toggleSet(prev, emotion))}
                aria-pressed={selectedEmotions.has(emotion)}
              >
                {emotion}
              </button>
            ))}
          </div>

          <h3 className="step-subtitle">What have you been doing?</h3>
          <div className="chip-grid" role="group" aria-label="Activity options">
            {ACTIVITY_OPTIONS.map((activity) => (
              <button
                key={activity}
                className={`chip ${selectedActivities.has(activity) ? 'chip-selected' : ''}`}
                onClick={() => setSelectedActivities((prev) => toggleSet(prev, activity))}
                aria-pressed={selectedActivities.has(activity)}
              >
                {activity}
              </button>
            ))}
          </div>

          <div className="step-navigation">
            <button className="back-button" onClick={() => setStep(1)}>Back</button>
            <button className="next-button" onClick={() => setStep(3)}>Continue</button>
          </div>
        </div>
      )}

      {step === 3 && (
        <div className="step step-notes" role="group" aria-label="Add notes">
          <h2 className="step-title">Anything else on your mind?</h2>
          <p className="step-description">Optional. Write as much or as little as you like.</p>
          <textarea
            className="notes-textarea"
            value={notes}
            onChange={(e) => setNotes(e.target.value)}
            placeholder="Journal your thoughts..."
            aria-label="Personal notes"
            rows={4}
          />

          <div className="step-navigation">
            <button className="back-button" onClick={() => setStep(2)}>Back</button>
            <button
              className="submit-button"
              onClick={handleSubmit}
              disabled={isSubmitting}
              aria-label="Save mood entry"
            >
              {isSubmitting ? 'Saving...' : 'Save Check-in'}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
Code collapsed

CSS with Accessibility and Calm Aesthetics

Create src/components/MoodCheckIn.css:

code
:root {
  --bg-primary: #0c0a1a;
  --bg-secondary: #1a1830;
  --bg-tertiary: #252240;
  --text-primary: #e8e4f0;
  --text-secondary: #9b95b0;
  --accent: #7c6ef0;
  --accent-hover: #9485f5;
  --border: #332e52;
  --radius: 12px;
  --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
  --transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

.mood-check-in {
  max-width: 480px;
  margin: 0 auto;
  padding: 24px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  color: var(--text-primary);
}

.step-title {
  font-size: 1.5rem;
  font-weight: 600;
  margin-bottom: 8px;
  letter-spacing: -0.02em;
}

.step-subtitle {
  font-size: 1.1rem;
  font-weight: 500;
  margin: 24px 0 12px;
}

.step-description {
  color: var(--text-secondary);
  font-size: 0.9rem;
  margin-bottom: 16px;
}

/* Mood scale buttons */
.mood-scale {
  display: flex;
  gap: 8px;
  margin: 20px 0;
}

.mood-button {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 16px 8px;
  border: 2px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-secondary);
  color: var(--text-secondary);
  cursor: pointer;
  transition: all var(--transition-fast);
}

.mood-button:hover {
  border-color: var(--mood-color, var(--accent));
  transform: translateY(-2px);
}

.mood-button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.mood-button-selected {
  background: color-mix(in srgb, var(--mood-color) 15%, var(--bg-secondary));
  border-color: var(--mood-color);
  color: var(--text-primary);
}

.mood-number {
  font-size: 1.4rem;
  font-weight: 700;
}

.mood-label {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

/* Emotion and activity chips */
.chip-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 16px;
}

.chip {
  padding: 8px 16px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 0.85rem;
  cursor: pointer;
  transition: all var(--transition-fast);
}

.chip:hover {
  border-color: var(--accent);
}

.chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.chip-selected {
  background: var(--accent);
  border-color: var(--accent);
  color: white;
}

/* Buttons */
.next-button,
.submit-button {
  padding: 12px 24px;
  background: var(--accent);
  color: white;
  border: none;
  border-radius: var(--radius);
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition-fast);
}

.next-button:hover:not(:disabled),
.submit-button:hover:not(:disabled) {
  background: var(--accent-hover);
}

.next-button:disabled,
.submit-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.back-button {
  padding: 12px 24px;
  background: transparent;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  transition: all var(--transition-fast);
}

.step-navigation {
  display: flex;
  justify-content: space-between;
  margin-top: 24px;
}

/* Notes textarea */
.notes-textarea {
  width: 100%;
  padding: 14px;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text-primary);
  font-size: 0.95rem;
  resize: vertical;
  min-height: 100px;
}

.notes-textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.notes-textarea::placeholder {
  color: var(--text-secondary);
}

/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
  .mood-button,
  .chip,
  .next-button,
  .submit-button,
  .back-button {
    transition: none;
  }
  .mood-button:hover {
    transform: none;
  }
}
Code collapsed

Trend Visualization Component

Create src/components/MoodTrends.tsx:

code
import React, { useEffect, useState } from 'react';
import { useMoodStore } from '../store/moodStore';
import { MoodTrend } from '../types/mood';

export default function MoodTrends() {
  const { currentTrend, loadTrend, entries, loadRecentEntries } = useMoodStore();
  const [period, setPeriod] = useState<'week' | 'month' | 'quarter'>('week');

  useEffect(() => {
    loadRecentEntries(90);
    loadTrend(period);
  }, [period]);

  const trend = currentTrend;
  if (!trend || trend.totalEntries === 0) {
    return (
      <div className="trends-empty">
        <p>Start logging your mood to see trends here.</p>
      </div>
    );
  }

  const getTrendDescription = (value: number, type: 'mood' | 'energy' | 'anxiety'): string => {
    if (type === 'anxiety') {
      if (value <= 2) return 'Low anxiety levels. Keep it up.';
      if (value <= 3) return 'Moderate anxiety. Consider relaxation techniques.';
      return 'Elevated anxiety. This may be worth discussing with a professional.';
    }
    if (value <= 2) return type === 'mood'
      ? 'Your mood has been lower recently. Be gentle with yourself.'
      : 'Energy levels have been low. Rest is important.';
    if (value <= 3.5) return 'Within the typical range.';
    return type === 'mood'
      ? 'Your mood has been generally positive.'
      : 'Energy levels look good.';
  };

  return (
    <div className="trends-container">
      <div className="period-selector">
        {(['week', 'month', 'quarter'] as const).map((p) => (
          <button
            key={p}
            className={`period-button ${period === p ? 'period-active' : ''}`}
            onClick={() => setPeriod(p)}
            aria-pressed={period === p}
          >
            {p === 'week' ? '7 Days' : p === 'month' ? '30 Days' : '90 Days'}
          </button>
        ))}
      </div>

      <div className="trend-metrics">
        <div className="metric-card">
          <span className="metric-label">Average Mood</span>
          <span className="metric-value">{trend.averageMood.toFixed(1)}/5</span>
          <span className="metric-description">{getTrendDescription(trend.averageMood, 'mood')}</span>
        </div>
        <div className="metric-card">
          <span className="metric-label">Average Energy</span>
          <span className="metric-value">{trend.averageEnergy.toFixed(1)}/5</span>
          <span className="metric-description">{getTrendDescription(trend.averageEnergy, 'energy')}</span>
        </div>
        <div className="metric-card">
          <span className="metric-label">Average Anxiety</span>
          <span className="metric-value">{trend.averageAnxiety.toFixed(1)}/5</span>
          <span className="metric-description">{getTrendDescription(trend.averageAnxiety, 'anxiety')}</span>
        </div>
      </div>

      <div className="emotions-section">
        <h3 className="section-title">Most Frequent Emotions</h3>
        <div className="emotion-bars">
          {trend.dominantEmotions.map(({ emotion, count }) => (
            <div key={emotion} className="emotion-bar-row">
              <span className="emotion-name">{emotion}</span>
              <div className="emotion-bar-track">
                <div
                  className="emotion-bar-fill"
                  style={{ width: `${(count / trend.totalEntries) * 100}%` }}
                />
              </div>
              <span className="emotion-count">{count}</span>
            </div>
          ))}
        </div>
      </div>

      <div className="variability-section">
        <h3 className="section-title">Mood Variability</h3>
        <p className="variability-text">
          {trend.moodVariability < 0.5
            ? 'Your mood has been quite stable recently. Stability can be a sign of emotional equilibrium.'
            : trend.moodVariability < 1.0
            ? 'Your mood shows some variation, which is completely normal.'
            : 'Your mood has been fluctuating more than usual. Consider tracking what factors might be contributing.'}
        </p>
      </div>

      <p className="entries-count">Based on {trend.totalEntries} entries</p>
    </div>
  );
}
Code collapsed

Performance Optimization Strategies

Several techniques ensure the app stays responsive even with large datasets:

Virtualized lists for history: When displaying months of mood entries, use a virtualized list to only render visible items:

code
import { useVirtualizer } from '@tanstack/react-virtual';

function MoodHistoryList({ entries }: { entries: MoodEntry[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: entries.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,
    overscan: 5,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((item) => (
          <MoodEntryRow
            key={item.key}
            entry={entries[item.index]}
            style={{
              position: 'absolute',
              top: item.start,
              height: item.size,
            }}
          />
        ))}
      </div>
    </div>
  );
}
Code collapsed

Debounced trend computation: Computing trends over 90 days of entries should not block the UI. Use a debounced computation with Web Workers:

code
// src/workers/trendWorker.ts
self.onmessage = (event) => {
  const { entries, period } = event.data;
  // Compute trend (same logic as moodStorage.computeTrend)
  // Post result back
  self.postMessage(computedTrend);
};
Code collapsed

Optimistic UI with rollback: As shown in the Zustand store, all mutations update the UI immediately, then persist to IndexedDB. If persistence fails, the state rolls back and the user sees a gentle error message rather than a frozen screen.

Accessibility Checklist

The app includes these accessibility features:

  • All interactive elements have descriptive aria-label attributes
  • Color is never the sole indicator of state (always paired with text labels)
  • Focus management is handled explicitly during step transitions
  • prefers-reduced-motion media query disables animations
  • Keyboard navigation works for all interactions
  • Form inputs have associated labels
  • Screen reader announcements for state changes via aria-live regions

Conclusion

Building a mood tracking app that truly serves its users requires attending to both performance and emotional design. The technical choices--optimistic updates, local-first storage, virtualized rendering--serve the human goal of reducing friction during vulnerable moments.

The key insight is that performance and empathy are not competing priorities. A fast-loading, smooth-interacting app is itself an act of respect for the user's emotional state. When someone opens a mood tracker to record that they are struggling, the last thing they need is a loading spinner or a confusing interface.

#

Article Tags

react
mood-tracking
accessibility
performance
mental-health

Found this article helpful?

Try KangXinBan and start your health management journey