WellAlly Logo
WellAlly康心伴
Development

CBT App Data Structures and React State Management

Design efficient data structures and state management for a Cognitive Behavioral Therapy app using React. This tutorial covers journaling models, thought records, mood tracking, and state patterns for offline-first CBT applications.

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

What You'll Build

You will design and implement the data structures and state management layer for a Cognitive Behavioral Therapy (CBT) application. The app will support structured thought records (the core CBT exercise), mood tracking with daily check-ins, journaling with guided prompts, and behavioral activation scheduling. The state architecture will be offline-first, using a local-first pattern that syncs when connectivity is available.

This data modeling approach mirrors the patterns we use at WellAlly for structuring mental health tools that respect user privacy while enabling clinical utility.

Prerequisites

  • React 19 with TypeScript 5.x
  • Familiarity with React Context and useReducer
  • Understanding of Zod for runtime validation
  • Knowledge of IndexedDB or SQLite for client-side storage
  • Basic understanding of CBT concepts (thought records, cognitive distortions, behavioral activation)

Tech Stack

TechnologyVersionPurpose
React19.xUI framework
TypeScript5.xType system
Zod3.xRuntime validation
useReducer + Contextbuilt-inState management
idb-keyval2.xIndexedDB wrapper
date-fns4.xDate handling

Architecture Overview

The state architecture uses a layered approach: domain models at the bottom, a state machine for each CBT module, a centralized store using useReducer, and a persistence layer backed by IndexedDB.

code
App
  ├── CBTProvider (Context + Reducer)
  │     ├── state: CBTAppState
  │     ├── dispatch: CBTAction
  │     └── selectors (memoized)
  ├── PersistenceLayer
  │     ├── IndexedDB Adapter
  │     ├── SyncQueue (pending mutations)
  │     └── ConflictResolver
  ├── Domain Models
  │     ├── ThoughtRecord
  │     ├── MoodEntry
  │     ├── JournalEntry
  │     ├── ActivitySchedule
  │     └── CognitiveDistortion
  └── Validation (Zod schemas)
Code collapsed

Step-by-Step Implementation

Step 1: Define Domain Models

Start with the core CBT data structures. These models represent the domain concepts that clinicians and patients interact with.

code
// src/models/types.ts

export type CognitiveDistortionType =
  | 'all_or_nothing_thinking'
  | 'catastrophizing'
  | 'emotional_reasoning'
  | 'fortune_telling'
  | 'labeling'
  | 'magnification'
  | 'mind_reading'
  | 'minimization'
  | 'overgeneralization'
  | 'personalization'
  | 'should_statements'
  | 'selective_abstraction';

export const cognitiveDistortionLabels: Record<CognitiveDistortionType, string> = {
  all_or_nothing_thinking: 'All-or-Nothing Thinking',
  catastrophizing: 'Catastrophizing',
  emotional_reasoning: 'Emotional Reasoning',
  fortune_telling: 'Fortune Telling',
  labeling: 'Labeling',
  magnification: 'Magnification',
  mind_reading: 'Mind Reading',
  minimization: 'Minimization',
  overgeneralization: 'Overgeneralization',
  personalization: 'Personalization',
  should_statements: 'Should Statements',
  selective_abstraction: 'Selective Abstraction',
};

export interface ThoughtRecord {
  id: string;
  createdAt: string;
  updatedAt: string;
  /** Step 1: Describe the situation */
  situation: string;
  /** Step 2: What emotions did you feel? */
  emotions: EmotionRating[];
  /** Step 3: What was the automatic thought? */
  automaticThought: string;
  /** Step 4: Evidence supporting the thought */
  evidenceFor: string;
  /** Step 5: Evidence against the thought */
  evidenceAgainst: string;
  /** Step 6: Identify cognitive distortions */
  cognitiveDistortions: CognitiveDistortionType[];
  /** Step 7: Generate an alternative balanced thought */
  alternativeThought: string;
  /** Step 8: Re-rate emotions after reframing */
  reratedEmotions: EmotionRating[];
  /** Completion status */
  completedSteps: number;
  isComplete: boolean;
}

export interface EmotionRating {
  emotion: string;
  intensityBefore: number; // 0-100
  intensityAfter: number;  // 0-100
}

export interface MoodEntry {
  id: string;
  date: string;
  overallMood: MoodLevel;
  energyLevel: number;     // 1-5
  anxietyLevel: number;    // 1-5
  sleepQuality: number;    // 1-5
  notes: string;
  tags: string[];
}

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

export const moodLabels: Record<MoodLevel, string> = {
  1: 'Very Low',
  2: 'Low',
  3: 'Neutral',
  4: 'Good',
  5: 'Great',
};

export interface JournalEntry {
  id: string;
  createdAt: string;
  updatedAt: string;
  promptType: JournalPromptType;
  prompt: string;
  content: string;
  wordCount: number;
  durationSeconds: number;
  linkedThoughtRecordId?: string;
  linkedMoodEntryId?: string;
}

export type JournalPromptType =
  | 'free_write'
  | 'gratitude'
  | 'behavioral_activation'
  | 'cognitive_restructuring'
  | 'safety_plan'
  | 'values_clarification';

export interface ActivitySchedule {
  id: string;
  date: string;
  activities: ScheduledActivity[];
  pleasureTotal: number;
  accomplishmentTotal: number;
}

export interface ScheduledActivity {
  id: string;
  timeSlot: string;
  activity: string;
  category: ActivityCategory;
  plannedPleasure: number;      // 0-10
  plannedAccomplishment: number; // 0-10
  actualPleasure?: number;
  actualAccomplishment?: number;
  completed: boolean;
}

export type ActivityCategory =
  | 'work'
  | 'social'
  | 'exercise'
  | 'leisure'
  | 'self_care'
  | 'chores'
  | 'learning'
  | 'other';
Code collapsed

Step 2: Zod Validation Schemas

Runtime validation ensures data integrity, especially important when data may come from IndexedDB after an app update changed the schema.

code
// src/models/schemas.ts

import { z } from 'zod';

export const EmotionRatingSchema = z.object({
  emotion: z.string().min(1, 'Emotion name is required'),
  intensityBefore: z.number().min(0).max(100),
  intensityAfter: z.number().min(0).max(100),
});

export const ThoughtRecordSchema = z.object({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
  situation: z.string().min(10, 'Please describe the situation in at least 10 characters'),
  emotions: z.array(EmotionRatingSchema).min(1, 'Add at least one emotion'),
  automaticThought: z.string().min(5, 'What thought went through your mind?'),
  evidenceFor: z.string(),
  evidenceAgainst: z.string(),
  cognitiveDistortions: z.array(z.string()),
  alternativeThought: z.string(),
  reratedEmotions: z.array(EmotionRatingSchema),
  completedSteps: z.number().min(0).max(8),
  isComplete: z.boolean(),
});

export const MoodEntrySchema = z.object({
  id: z.string().uuid(),
  date: z.string().date(),
  overallMood: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]),
  energyLevel: z.number().min(1).max(5),
  anxietyLevel: z.number().min(1).max(5),
  sleepQuality: z.number().min(1).max(5),
  notes: z.string(),
  tags: z.array(z.string()),
});

export const JournalEntrySchema = z.object({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
  promptType: z.enum([
    'free_write', 'gratitude', 'behavioral_activation',
    'cognitive_restructuring', 'safety_plan', 'values_clarification',
  ]),
  prompt: z.string(),
  content: z.string().min(1),
  wordCount: z.number().min(0),
  durationSeconds: z.number().min(0),
  linkedThoughtRecordId: z.string().uuid().optional(),
  linkedMoodEntryId: z.string().uuid().optional(),
});

export const ScheduledActivitySchema = z.object({
  id: z.string().uuid(),
  timeSlot: z.string(),
  activity: z.string().min(1),
  category: z.enum(['work', 'social', 'exercise', 'leisure', 'self_care', 'chores', 'learning', 'other']),
  plannedPleasure: z.number().min(0).max(10),
  plannedAccomplishment: z.number().min(0).max(10),
  actualPleasure: z.number().min(0).max(10).optional(),
  actualAccomplishment: z.number().min(0).max(10).optional(),
  completed: z.boolean(),
});

export const ActivityScheduleSchema = z.object({
  id: z.string().uuid(),
  date: z.string().date(),
  activities: z.array(ScheduledActivitySchema),
  pleasureTotal: z.number(),
  accomplishmentTotal: z.number(),
});
Code collapsed

Step 3: State Management with useReducer

Design the app state and reducer. Using a discriminated union for actions ensures type safety across all state transitions.

code
// src/state/types.ts

import type {
  ThoughtRecord,
  MoodEntry,
  JournalEntry,
  ActivitySchedule,
} from '../models/types';

export interface CBTAppState {
  // Thought Records
  thoughtRecords: ThoughtRecord[];
  currentThoughtRecord: ThoughtRecord | null;
  thoughtRecordsLoaded: boolean;

  // Mood Entries
  moodEntries: MoodEntry[];
  todayMoodEntry: MoodEntry | null;
  moodEntriesLoaded: boolean;

  // Journal
  journalEntries: JournalEntry[];
  currentJournalEntry: JournalEntry | null;
  journalLoaded: boolean;

  // Activity Schedules
  activitySchedules: ActivitySchedule[];
  todaySchedule: ActivitySchedule | null;
  schedulesLoaded: boolean;

  // UI State
  activeStep: number;
  isSyncing: boolean;
  lastSyncAt: string | null;
  error: string | null;
}

export type CBTAction =
  // Thought Record actions
  | { type: 'THOUGHT_RECORD/START'; payload: ThoughtRecord }
  | { type: 'THOUGHT_RECORD/UPDATE_STEP'; payload: { id: string; step: number; data: Partial<ThoughtRecord> } }
  | { type: 'THOUGHT_RECORD/COMPLETE'; payload: ThoughtRecord }
  | { type: 'THOUGHT_RECORD/LOAD_ALL'; payload: ThoughtRecord[] }

  // Mood Entry actions
  | { type: 'MOOD/SAVE'; payload: MoodEntry }
  | { type: 'MOOD/UPDATE'; payload: MoodEntry }
  | { type: 'MOOD/LOAD_ALL'; payload: MoodEntry[] }

  // Journal actions
  | { type: 'JOURNAL/START'; payload: JournalEntry }
  | { type: 'JOURNAL/SAVE'; payload: JournalEntry }
  | { type: 'JOURNAL/LOAD_ALL'; payload: JournalEntry[] }

  // Activity Schedule actions
  | { type: 'SCHEDULE/SAVE'; payload: ActivitySchedule }
  | { type: 'SCHEDULE/UPDATE_ACTIVITY'; payload: { scheduleId: string; activityId: string; updates: Partial<import('../models/types').ScheduledActivity> } }
  | { type: 'SCHEDULE/LOAD_ALL'; payload: ActivitySchedule[] }

  // UI actions
  | { type: 'UI/SET_STEP'; payload: number }
  | { type: 'UI/SET_SYNCING'; payload: boolean }
  | { type: 'UI/SET_ERROR'; payload: string | null };
Code collapsed

Step 4: Reducer Implementation

code
// src/state/reducer.ts

import type { CBTAppState, CBTAction } from './types';
import type { MoodEntry, ActivitySchedule, ScheduledActivity } from '../models/types';

export const initialCBTState: CBTAppState = {
  thoughtRecords: [],
  currentThoughtRecord: null,
  thoughtRecordsLoaded: false,

  moodEntries: [],
  todayMoodEntry: null,
  moodEntriesLoaded: false,

  journalEntries: [],
  currentJournalEntry: null,
  journalLoaded: false,

  activitySchedules: [],
  todaySchedule: null,
  schedulesLoaded: false,

  activeStep: 1,
  isSyncing: false,
  lastSyncAt: null,
  error: null,
};

export function cbtReducer(state: CBTAppState, action: CBTAction): CBTAppState {
  switch (action.type) {
    // --- Thought Records ---
    case 'THOUGHT_RECORD/START':
      return {
        ...state,
        currentThoughtRecord: action.payload,
        activeStep: 1,
      };

    case 'THOUGHT_RECORD/UPDATE_STEP': {
      const { id, step, data } = action.payload;
      if (!state.currentThoughtRecord || state.currentThoughtRecord.id !== id) {
        return state;
      }
      const updatedRecord = {
        ...state.currentThoughtRecord,
        ...data,
        completedSteps: Math.max(state.currentThoughtRecord.completedSteps, step),
        updatedAt: new Date().toISOString(),
      };
      return {
        ...state,
        currentThoughtRecord: updatedRecord,
        activeStep: step + 1,
      };
    }

    case 'THOUGHT_RECORD/COMPLETE': {
      const completedRecord = { ...action.payload, isComplete: true, updatedAt: new Date().toISOString() };
      return {
        ...state,
        thoughtRecords: [completedRecord, ...state.thoughtRecords],
        currentThoughtRecord: null,
        activeStep: 1,
      };
    }

    case 'THOUGHT_RECORD/LOAD_ALL':
      return {
        ...state,
        thoughtRecords: action.payload,
        thoughtRecordsLoaded: true,
      };

    // --- Mood Entries ---
    case 'MOOD/SAVE': {
      const today = new Date().toISOString().split('T')[0];
      const isToday = action.payload.date === today;
      return {
        ...state,
        moodEntries: [action.payload, ...state.moodEntries.filter((m) => m.id !== action.payload.id)],
        todayMoodEntry: isToday ? action.payload : state.todayMoodEntry,
      };
    }

    case 'MOOD/UPDATE': {
      return {
        ...state,
        moodEntries: state.moodEntries.map((m) => m.id === action.payload.id ? action.payload : m),
        todayMoodEntry: state.todayMoodEntry?.id === action.payload.id ? action.payload : state.todayMoodEntry,
      };
    }

    case 'MOOD/LOAD_ALL': {
      const today = new Date().toISOString().split('T')[0];
      return {
        ...state,
        moodEntries: action.payload,
        todayMoodEntry: action.payload.find((m) => m.date === today) ?? null,
        moodEntriesLoaded: true,
      };
    }

    // --- Journal ---
    case 'JOURNAL/START':
      return {
        ...state,
        currentJournalEntry: action.payload,
      };

    case 'JOURNAL/SAVE': {
      const entry = { ...action.payload, updatedAt: new Date().toISOString() };
      return {
        ...state,
        journalEntries: [entry, ...state.journalEntries.filter((j) => j.id !== entry.id)],
        currentJournalEntry: null,
      };
    }

    case 'JOURNAL/LOAD_ALL':
      return {
        ...state,
        journalEntries: action.payload,
        journalLoaded: true,
      };

    // --- Activity Schedules ---
    case 'SCHEDULE/SAVE': {
      const today = new Date().toISOString().split('T')[0];
      const isToday = action.payload.date === today;
      return {
        ...state,
        activitySchedules: [action.payload, ...state.activitySchedules.filter((s) => s.id !== action.payload.id)],
        todaySchedule: isToday ? action.payload : state.todaySchedule,
      };
    }

    case 'SCHEDULE/UPDATE_ACTIVITY': {
      const { scheduleId, activityId, updates } = action.payload;
      const updateActivities = (schedule: ActivitySchedule): ActivitySchedule => {
        if (schedule.id !== scheduleId) return schedule;
        return {
          ...schedule,
          activities: schedule.activities.map((a: ScheduledActivity) =>
            a.id === activityId ? { ...a, ...updates } : a
          ),
        };
      };
      return {
        ...state,
        activitySchedules: state.activitySchedules.map(updateActivities),
        todaySchedule: state.todaySchedule ? updateActivities(state.todaySchedule) : null,
      };
    }

    case 'SCHEDULE/LOAD_ALL': {
      const today = new Date().toISOString().split('T')[0];
      return {
        ...state,
        activitySchedules: action.payload,
        todaySchedule: action.payload.find((s) => s.date === today) ?? null,
        schedulesLoaded: true,
      };
    }

    // --- UI ---
    case 'UI/SET_STEP':
      return { ...state, activeStep: action.payload };
    case 'UI/SET_SYNCING':
      return { ...state, isSyncing: action.payload };
    case 'UI/SET_ERROR':
      return { ...state, error: action.payload };

    default:
      return state;
  }
}
Code collapsed

Step 5: Persistence Layer

code
// src/persistence/IndexedDBAdapter.ts

import { get, set, del, keys } from 'idb-keyval';
import type { ThoughtRecord, MoodEntry, JournalEntry, ActivitySchedule } from '../models/types';

type PersistedEntity = ThoughtRecord | MoodEntry | JournalEntry | ActivitySchedule;

const STORE_PREFIXES = {
  thoughtRecord: 'tr',
  moodEntry: 'mood',
  journalEntry: 'journal',
  activitySchedule: 'schedule',
} as const;

function getKey(prefix: string, id: string): string {
  return `${prefix}:${id}`;
}

export const persistenceAdapter = {
  async saveThoughtRecord(record: ThoughtRecord): Promise<void> {
    await set(getKey(STORE_PREFIXES.thoughtRecord, record.id), record);
  },

  async loadAllThoughtRecords(): Promise<ThoughtRecord[]> {
    return this.loadAll<ThoughtRecord>(STORE_PREFIXES.thoughtRecord);
  },

  async saveMoodEntry(entry: MoodEntry): Promise<void> {
    await set(getKey(STORE_PREFIXES.moodEntry, entry.id), entry);
  },

  async loadAllMoodEntries(): Promise<MoodEntry[]> {
    return this.loadAll<MoodEntry>(STORE_PREFIXES.moodEntry);
  },

  async saveJournalEntry(entry: JournalEntry): Promise<void> {
    await set(getKey(STORE_PREFIXES.journalEntry, entry.id), entry);
  },

  async loadAllJournalEntries(): Promise<JournalEntry[]> {
    return this.loadAll<JournalEntry>(STORE_PREFIXES.journalEntry);
  },

  async saveActivitySchedule(schedule: ActivitySchedule): Promise<void> {
    await set(getKey(STORE_PREFIXES.activitySchedule, schedule.id), schedule);
  },

  async loadAllActivitySchedules(): Promise<ActivitySchedule[]> {
    return this.loadAll<ActivitySchedule>(STORE_PREFIXES.activitySchedule);
  },

  async deleteEntity(prefix: string, id: string): Promise<void> {
    await del(getKey(prefix, id));
  },

  async loadAll<T>(prefix: string): Promise<T[]> {
    const allKeys = await keys();
    const matchingKeys = allKeys.filter((k) => (k as string).startsWith(`${prefix}:`));
    const results: T[] = [];
    for (const key of matchingKeys) {
      const value = await get<T>(key);
      if (value) results.push(value);
    }
    // Sort by createdAt or date descending
    results.sort((a, b) => {
      const dateA = (a as any).createdAt || (a as any).date || '';
      const dateB = (b as any).createdAt || (b as any).date || '';
      return dateB.localeCompare(dateA);
    });
    return results;
  },
};
Code collapsed

Step 6: React Context Provider

code
// src/state/CBTProvider.tsx

import React, { createContext, useContext, useReducer, useEffect, useCallback, useRef } from 'react';
import { cbtReducer, initialCBTState } from './reducer';
import { persistenceAdapter } from '../persistence/IndexedDBAdapter';
import type { CBTAppState, CBTAction } from './types';
import type { ThoughtRecord, MoodEntry, JournalEntry, ActivitySchedule } from '../models/types';

interface CBTContextValue {
  state: CBTAppState;
  dispatch: React.Dispatch<CBTAction>;
  // Convenience methods
  startThoughtRecord: () => ThoughtRecord;
  updateThoughtRecordStep: (id: string, step: number, data: Partial<ThoughtRecord>) => void;
  saveMoodEntry: (entry: MoodEntry) => Promise<void>;
  saveJournalEntry: (entry: JournalEntry) => Promise<void>;
  saveActivitySchedule: (schedule: ActivitySchedule) => Promise<void>;
}

const CBTContext = createContext<CBTContextValue | null>(null);

function generateId(): string {
  return crypto.randomUUID();
}

export function CBTProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cbtReducer, initialCBTState);
  const saveQueueRef = useRef<Map<string, Promise<void>>>(new Map());

  // Load persisted data on mount
  useEffect(() => {
    async function loadPersistedData() {
      try {
        const [thoughtRecords, moodEntries, journalEntries, activitySchedules] = await Promise.all([
          persistenceAdapter.loadAllThoughtRecords(),
          persistenceAdapter.loadAllMoodEntries(),
          persistenceAdapter.loadAllJournalEntries(),
          persistenceAdapter.loadAllActivitySchedules(),
        ]);

        dispatch({ type: 'THOUGHT_RECORD/LOAD_ALL', payload: thoughtRecords });
        dispatch({ type: 'MOOD/LOAD_ALL', payload: moodEntries });
        dispatch({ type: 'JOURNAL/LOAD_ALL', payload: journalEntries });
        dispatch({ type: 'SCHEDULE/LOAD_ALL', payload: activitySchedules });
      } catch (error) {
        dispatch({ type: 'UI/SET_ERROR', payload: 'Failed to load saved data' });
      }
    }
    loadPersistedData();
  }, []);

  // Persist thought records changes
  useEffect(() => {
    if (!state.thoughtRecordsLoaded) return;
    for (const record of state.thoughtRecords) {
      debouncePersist('tr', record.id, () => persistenceAdapter.saveThoughtRecord(record));
    }
  }, [state.thoughtRecords, state.thoughtRecordsLoaded]);

  const debouncePersist = (prefix: string, id: string, persistFn: () => Promise<void>) => {
    const key = `${prefix}:${id}`;
    const existing = saveQueueRef.current.get(key);
    if (existing) return;

    const promise = persistFn().finally(() => {
      saveQueueRef.current.delete(key);
    });
    saveQueueRef.current.set(key, promise);
  };

  const startThoughtRecord = useCallback((): ThoughtRecord => {
    const now = new Date().toISOString();
    const record: ThoughtRecord = {
      id: generateId(),
      createdAt: now,
      updatedAt: now,
      situation: '',
      emotions: [],
      automaticThought: '',
      evidenceFor: '',
      evidenceAgainst: '',
      cognitiveDistortions: [],
      alternativeThought: '',
      reratedEmotions: [],
      completedSteps: 0,
      isComplete: false,
    };
    dispatch({ type: 'THOUGHT_RECORD/START', payload: record });
    return record;
  }, []);

  const updateThoughtRecordStep = useCallback(
    (id: string, step: number, data: Partial<ThoughtRecord>) => {
      dispatch({ type: 'THOUGHT_RECORD/UPDATE_STEP', payload: { id, step, data } });
    },
    []
  );

  const saveMoodEntry = useCallback(async (entry: MoodEntry) => {
    dispatch({ type: 'MOOD/SAVE', payload: entry });
    await persistenceAdapter.saveMoodEntry(entry);
  }, []);

  const saveJournalEntry = useCallback(async (entry: JournalEntry) => {
    dispatch({ type: 'JOURNAL/SAVE', payload: entry });
    await persistenceAdapter.saveJournalEntry(entry);
  }, []);

  const saveActivitySchedule = useCallback(async (schedule: ActivitySchedule) => {
    dispatch({ type: 'SCHEDULE/SAVE', payload: schedule });
    await persistenceAdapter.saveActivitySchedule(schedule);
  }, []);

  return (
    <CBTContext.Provider
      value={{
        state,
        dispatch,
        startThoughtRecord,
        updateThoughtRecordStep,
        saveMoodEntry,
        saveJournalEntry,
        saveActivitySchedule,
      }}
    >
      {children}
    </CBTContext.Provider>
  );
}

export function useCBT() {
  const context = useContext(CBTContext);
  if (!context) {
    throw new Error('useCBT must be used within a CBTProvider');
  }
  return context;
}
Code collapsed

Step 7: Thought Record Wizard Component

A step-by-step wizard for completing a CBT thought record, demonstrating how the data structures and state management work together.

code
// src/components/ThoughtRecordWizard.tsx

import React, { useState, useCallback } from 'react';
import { useCBT } from '../state/CBTProvider';
import { ThoughtRecordSchema } from '../models/schemas';
import { cognitiveDistortionLabels } from '../models/types';
import type { CognitiveDistortionType, EmotionRating } from '../models/types';

const STEPS = [
  'Situation',
  'Emotions',
  'Automatic Thought',
  'Evidence For',
  'Evidence Against',
  'Cognitive Distortions',
  'Alternative Thought',
  'Re-rate Emotions',
];

export function ThoughtRecordWizard() {
  const { state, startThoughtRecord, updateThoughtRecordStep, dispatch } = useCBT();
  const [newEmotion, setNewEmotion] = useState('');
  const [newIntensity, setNewIntensity] = useState(50);

  const current = state.currentThoughtRecord;

  const handleStart = useCallback(() => {
    startThoughtRecord();
  }, [startThoughtRecord]);

  const handleNext = useCallback(() => {
    if (!current) return;
    updateThoughtRecordStep(current.id, state.activeStep, {});
  }, [current, state.activeStep, updateThoughtRecordStep]);

  const handleAddEmotion = useCallback(() => {
    if (!current || !newEmotion.trim()) return;
    const newRating: EmotionRating = {
      emotion: newEmotion.trim(),
      intensityBefore: newIntensity,
      intensityAfter: newIntensity,
    };
    updateThoughtRecordStep(current.id, state.activeStep, {
      emotions: [...current.emotions, newRating],
    });
    setNewEmotion('');
    setNewIntensity(50);
  }, [current, newEmotion, newIntensity, state.activeStep, updateThoughtRecordStep]);

  const handleToggleDistortion = useCallback(
    (distortion: CognitiveDistortionType) => {
      if (!current) return;
      const existing = current.cognitiveDistortions;
      const updated = existing.includes(distortion)
        ? existing.filter((d) => d !== distortion)
        : [...existing, distortion];
      updateThoughtRecordStep(current.id, state.activeStep, {
        cognitiveDistortions: updated,
      });
    },
    [current, state.activeStep, updateThoughtRecordStep]
  );

  const handleComplete = useCallback(() => {
    if (!current) return;
    const result = ThoughtRecordSchema.safeParse(current);
    if (!result.success) {
      dispatch({ type: 'UI/SET_ERROR', payload: 'Please complete all required fields.' });
      return;
    }
    dispatch({ type: 'THOUGHT_RECORD/COMPLETE', payload: current });
  }, [current, dispatch]);

  if (!current) {
    return (
      <div className="max-w-xl mx-auto p-6">
        <h2 className="text-xl font-bold mb-4">New Thought Record</h2>
        <p className="text-gray-600 mb-6">
          A thought record helps you identify and challenge unhelpful thoughts. It takes about 5-10 minutes to complete.
        </p>
        <button
          onClick={handleStart}
          className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
        >
          Start Thought Record
        </button>
      </div>
    );
  }

  return (
    <div className="max-w-xl mx-auto p-6">
      {/* Progress indicator */}
      <div className="flex items-center gap-2 mb-8" role="progressbar" aria-valuenow={state.activeStep} aria-valuemin={1} aria-valuemax={8}>
        {STEPS.map((label, index) => (
          <div
            key={index}
            className={`flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium ${
              index + 1 <= state.activeStep
                ? 'bg-blue-600 text-white'
                : 'bg-gray-200 text-gray-500'
            }`}
            aria-label={`Step ${index + 1}: ${label}`}
          >
            {index + 1}
          </div>
        ))}
      </div>

      <h2 className="text-lg font-semibold mb-4">{STEPS[state.activeStep - 1]}</h2>

      {/* Step content rendered based on activeStep */}
      {state.activeStep === 1 && (
        <div>
          <label className="block text-sm font-medium text-gray-700 mb-2">
            Describe the situation that triggered the thought
          </label>
          <textarea
            className="w-full border rounded-lg p-3 h-32 text-gray-900"
            value={current.situation}
            onChange={(e) =>
              updateThoughtRecordStep(current.id, state.activeStep, { situation: e.target.value })
            }
            placeholder="What happened? Where were you? Who was there?"
          />
        </div>
      )}

      {state.activeStep === 6 && (
        <div>
          <p className="text-sm text-gray-600 mb-4">Select any cognitive distortions you notice:</p>
          <div className="grid grid-cols-2 gap-2">
            {(Object.entries(cognitiveDistortionLabels) as [CognitiveDistortionType, string][]).map(
              ([key, label]) => (
                <button
                  key={key}
                  onClick={() => handleToggleDistortion(key)}
                  className={`p-2 text-sm rounded-lg border text-left transition-colors ${
                    current.cognitiveDistortions.includes(key)
                      ? 'bg-blue-100 border-blue-400 text-blue-800'
                      : 'bg-white border-gray-200 text-gray-700 hover:border-gray-300'
                  }`}
                >
                  {label}
                </button>
              )
            )}
          </div>
        </div>
      )}

      {state.error && (
        <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm" role="alert">
          {state.error}
        </div>
      )}

      <div className="flex justify-between mt-8">
        <button
          onClick={() => dispatch({ type: 'UI/SET_STEP', payload: Math.max(1, state.activeStep - 1) })}
          disabled={state.activeStep === 1}
          className="px-4 py-2 text-gray-600 disabled:opacity-50"
        >
          Previous
        </button>
        {state.activeStep < 8 ? (
          <button
            onClick={handleNext}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700"
          >
            Next
          </button>
        ) : (
          <button
            onClick={handleComplete}
            className="bg-green-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-green-700"
          >
            Complete
          </button>
        )}
      </div>
    </div>
  );
}
Code collapsed

Best Practices

Data Integrity

  • Validate at the boundary: Always validate data with Zod when it enters your system (from IndexedDB, API responses, or user input).
  • Use discriminated union actions: The CBTAction type ensures that every dispatch has the correct payload shape at compile time.
  • Never mutate state in the reducer: Always return new objects. Use the spread operator or immer if the nesting is deep.

Offline-First Design

  • Write to local storage first: Persist to IndexedDB immediately on user action. Sync to the server in the background.
  • Queue failed syncs: If the server is unreachable, queue the mutation and retry when connectivity is restored.
  • Resolve conflicts with last-write-wins: For CBT data, the user's latest edit is the source of truth. Timestamp-based conflict resolution is sufficient.

Privacy

  • Encrypt data at rest: Use the Web Crypto API to encrypt thought records and journal entries stored in IndexedDB.
  • No analytics on content: Never send the actual content of thought records or journal entries to analytics services.
  • Allow full data deletion: Provide a single button that clears all IndexedDB stores and server-side data.

Common Pitfalls

  1. Storing emotion arrays without fixed schema: Emotion names are free-text, which makes aggregation difficult. Provide a curated list of common emotions while allowing custom entries.

  2. Not handling partial saves: A thought record has 8 steps. Users will leave mid-completion. Always persist partial records and allow resuming.

  3. Using useState for complex forms: The thought record wizard has interdependent fields across steps. useReducer with a single state object is far more maintainable than multiple useState calls.

  4. Ignoring IndexedDB storage quotas: On some browsers, IndexedDB has size limits. Implement a data archival strategy for old thought records.

  5. Not testing the persistence layer independently: Test IndexedDB read/write operations in isolation to catch schema migration issues early.

Deploying to Production

  • Set up a sync endpoint that accepts thought records, mood entries, and journal entries as separate resources.
  • Implement differential sync: Send only records modified since the last sync timestamp to reduce bandwidth.
  • Add data export: Allow users to export their CBT data as JSON or PDF for sharing with their therapist.
  • Use a service worker to cache the app shell so the CBT tools work even when offline.
  • Run accessibility audits: CBT tools must be usable by people experiencing anxiety or depression, who may have reduced cognitive bandwidth. Keep the UI simple and predictable.

Complete Code

Key files in this implementation:

  • Domain types: src/models/types.ts
  • Validation schemas: src/models/schemas.ts
  • State types: src/state/types.ts
  • Reducer: src/state/reducer.ts
  • Persistence: src/persistence/IndexedDBAdapter.ts
  • Provider: src/state/CBTProvider.tsx
  • Wizard: src/components/ThoughtRecordWizard.tsx

Resources

#

Article Tags

React
State Management
CBT
Mental Health
TypeScript

Found this article helpful?

Try KangXinBan and start your health management journey