The market for mental health apps is booming, projected to grow significantly as more people seek accessible support. Many of these apps are built on principles of Cognitive Behavioral Therapy (CBT), a technique that helps people identify and change destructive thinking patterns.
At first glance, a CBT app might seem like a simple forms-over-data application. But how do you guide a user through a structured, multi-step therapeutic exercise like a "thought record"? How do you manage the intricate state as the user reflects and inputs deeply personal data? This isn't just another to-do list app.
In this deep dive, we'll build a simplified but powerful CBT "Thought Record" feature using React. We'll explore how to model the data, architect a robust multi-step form, and manage its state effectively. This article is for developers interested in the intersection of mental health and technology, or anyone looking to level up their skills in managing complex state in React.
Prerequisites:
- Solid understanding of React (including hooks like
useStateanduseEffect). - Basic knowledge of database concepts (relational vs. NoSQL).
- Node.js and npm/yarn installed.
Understanding the Problem: The Thought Record
A cornerstone of CBT is the "thought record." It's a structured exercise that helps users deconstruct anxious or negative thoughts. A typical flow looks like this:
- Situation: Describe the event that triggered the thought.
- Automatic Thought(s): What was the immediate thought or image that came to mind?
- Emotions: What emotions did this thought provoke? (e.g., Sadness, Anxiety). Rate their intensity.
- Evidence For: What facts support this automatic thought?
- Evidence Against: What facts contradict this automatic thought?
- Alternative Thought: Create a more balanced or realistic thought.
- Outcome: Re-rate the intensity of the initial emotions.
This multi-step, sequential process is a perfect case study for a technical challenge. We need to:
- Guide the user from one step to the next.
- Allow them to go back and change previous answers.
- Hold the entire record in state until it's complete.
- Store the final record in a database in a structured way.
Step 1: Modeling the Data Structure
Before writing a single line of React, we must define our data structure. A well-designed structure will make state management and database storage much simpler. We can model the thought record as a single object.
The Data Model
Here's a JavaScript object that represents a complete thought record.
// src/data/thoughtRecordModel.js
const thoughtRecord = {
id: 'uuid-1234',
userId: 'user-abc',
createdAt: '2025-12-11T10:00:00Z',
situation: 'I made a mistake in my presentation at work.',
automaticThoughts: 'Everyone thinks I am incompetent.',
initialEmotions: [
{ name: 'Anxiety', intensity: 8 },
{ name: 'Shame', intensity: 7 }
],
evidenceFor: 'A colleague pointed out the error.',
evidenceAgainst: 'My manager said the rest of the presentation was great. I have received positive feedback before.',
alternativeThought: 'Making a mistake is human. It does not define my overall competence. I can learn from it.',
outcomeEmotions: [
{ name: 'Anxiety', intensity: 3 },
{ name: 'Shame', intensity: 2 }
],
isComplete: true,
};
Database Schema Design
This structure can be mapped to different database types.
1. Relational Database (e.g., PostgreSQL)
You'd likely have a few tables.
users
id(PK)emailpassword_hash
thought_records
id(PK)user_id(FK to users.id)created_atsituation(TEXT)automatic_thoughts(TEXT)evidence_for(TEXT)evidence_against(TEXT)alternative_thought(TEXT)is_complete(BOOLEAN)
emotions
id(PK)record_id(FK to thought_records.id)name(VARCHAR)intensity(INTEGER)type(ENUM: 'initial', 'outcome')
2. NoSQL Database (e.g., MongoDB)
With a document-based database, our JavaScript model maps almost directly to a single document in a thoughtRecords collection. This simplicity is often a great choice for this kind of nested data.
{
"_id": ObjectId("..."),
"userId": ObjectId("..."),
"createdAt": ISODate("..."),
"situation": "I made a mistake...",
"automaticThoughts": "Everyone thinks I am incompetent.",
"initialEmotions": [
{ "name": "Anxiety", "intensity": 8 },
{ "name": "Shame", "intensity": 7 }
],
// ... and so on
}
For this tutorial, we'll focus on the frontend, assuming an API will eventually consume this data structure.
Step 2: Architecting the State with useReducer
For a multi-step form, managing state with multiple useState hooks can become chaotic. This is a perfect job for the useReducer hook. It centralizes our state logic, making it more predictable and easier to debug, similar to the Redux pattern.
Defining the State and Actions
Let's create a reducer to manage our thought record form.
// src/hooks/useThoughtRecord.js
export const initialState = {
step: 1, // Current step in the form
record: {
situation: '',
automaticThoughts: '',
initialEmotions: [],
evidenceFor: '',
evidenceAgainst: '',
alternativeThought: '',
outcomeEmotions: [],
},
isComplete: false,
};
export function thoughtRecordReducer(state, action) {
switch (action.type) {
case 'NEXT_STEP':
return { ...state, step: state.step + 1 };
case 'PREVIOUS_STEP':
return { ...state, step: state.step - 1 };
case 'UPDATE_FIELD':
return {
...state,
record: {
...state.record,
[action.payload.field]: action.payload.value,
},
};
case 'SUBMIT':
return { ...state, isComplete: true };
case 'RESET':
return initialState;
default:
throw new Error('Unhandled action type');
}
}
How It Works
initialState: Defines the shape of our state, including the currentstepand therecorddata.thoughtRecordReducer: This pure function takes the currentstateand anactionand returns the new state.NEXT_STEP/PREVIOUS_STEP: Simple transitions that just increment or decrement the step number.UPDATE_FIELD: A flexible action to update any field in ourrecordobject.SUBMIT: Marks the process as complete.
Step 3: Building the Multi-Step Form Components
Now, let's build the React components. We'll have a main ThoughtRecordFlow component that uses our reducer, and it will conditionally render the component for the current step.
The Main Flow Component
// src/components/ThoughtRecordFlow.js
import React, { useReducer } from 'react';
import { initialState, thoughtRecordReducer } from '../hooks/useThoughtRecord';
// Import step components (we'll create one of these next)
import Step1_Situation from './steps/Step1_Situation';
import Step2_AutomaticThoughts from './steps/Step2_AutomaticThoughts';
// ... other step components
const ThoughtRecordFlow = () => {
const [state, dispatch] = useReducer(thoughtRecordReducer, initialState);
const { step, record } = state;
const renderStep = () => {
switch (step) {
case 1:
return <Step1_Situation record={record} dispatch={dispatch} />;
case 2:
return <Step2_AutomaticThoughts record={record} dispatch={dispatch} />;
// ... cases for other steps
default:
return <Step1_Situation record={record} dispatch={dispatch} />;
}
};
return (
<div className="cbt-flow-container">
<h2>Thought Record - Step {step} of 7</h2>
{renderStep()}
</div>
);
};
export default ThoughtRecordFlow;
This component acts as our state manager and router for the form steps.
A Sample Step Component
Let's look at what Step1_Situation.js would look like.
// src/components/steps/Step1_Situation.js
import React from 'react';
const Step1_Situation = ({ record, dispatch }) => {
const { situation } = record;
const handleChange = (e) => {
dispatch({
type: 'UPDATE_FIELD',
payload: { field: 'situation', value: e.target.value },
});
};
const handleNext = () => {
// Optional: Add validation here
if (situation.trim()) {
dispatch({ type: 'NEXT_STEP' });
}
};
return (
<div className="step-container">
<p>Describe the situation that triggered your difficult thought.</p>
<textarea
value={situation}
onChange={handleChange}
placeholder="e.g., I was giving a presentation and my mind went blank."
/>
<div className="navigation-buttons">
<button onClick={handleNext} disabled={!situation.trim()}>
Next →
</button>
</div>
</div>
);
};
export default Step1_Situation;
How It Works
- The component receives the current
recordstate and thedispatchfunction as props. - The
textareais a controlled component, with its value tied torecord.situation. - On every change, it dispatches an
UPDATE_FIELDaction. - The "Next" button dispatches the
NEXT_STEPaction to advance the flow.
You would create similar components for each step of the thought record, each managing its own piece of the state via the centralized reducer.
Alternative Approach: Finite State Machines
Our useReducer implementation is excellent, but as flows get more complex (e.g., conditional steps, different user paths), it can be beneficial to use a finite state machine (FSM).
An FSM is a model where an application can be in only one of a finite number of states at any given time. A transition from one state to another is triggered by an event.
This perfectly describes our CBT exercise!
- States:
welcoming,writingSituation,identifyingThoughts,challenging,concluding. - Events:
START_EXERCISE,NEXT,PREVIOUS,SAVE_DRAFT.
Libraries like XState allow you to define these flows declaratively.
// A conceptual XState machine definition
import { createMachine } from 'xstate';
const thoughtRecordMachine = createMachine({
id: 'thoughtRecord',
initial: 'situation',
context: { /* our record data goes here */ },
states: {
situation: {
on: { NEXT: 'automaticThoughts' }
},
automaticThoughts: {
on: { NEXT: 'initialEmotions', PREVIOUS: 'situation' }
},
// ... more states
}
});
Why use a state machine?
- Clarity: The entire user flow is defined in one place.
- Safety: It prevents impossible state transitions (e.g., skipping a step).
- Visualization: You can automatically generate visual diagrams of your user flows from the machine definition, which is amazing for documentation and team communication.
Security Best Practices
When building a mental health app, security and privacy are not optional features—they are the foundation of user trust. Mishandling sensitive data can have severe consequences.
- Compliance: Be aware of regulations like HIPAA (in the US) and GDPR (in the EU). These have strict rules about handling protected health information (PHI).
- Data Encryption: All data must be encrypted in transit (using HTTPS/TLS) and at rest (encrypting the database itself).
- Authentication & Authorization: Use robust authentication methods (e.g., OAuth 2.0). Ensure a user can only ever access their own data.
- Anonymity: Whenever possible, allow users to engage with features without providing unnecessary personally identifiable information (PII).
- Transparent Privacy Policies: Clearly explain what data you collect and how it's used.
Conclusion
We've successfully broken down a complex therapeutic exercise into a manageable and scalable technical architecture. By starting with a clear data model, leveraging React's useReducer for centralized state management, and structuring our UI into discrete steps, we've built the foundation for a powerful and responsible CBT application.
You've learned to:
- Model a real-world, multi-step process into a clean data structure.
- Implement a robust multi-step form using
useReducer. - Recognize when a finite state machine might be a better tool for the job.
- Consider the critical security and privacy implications of building mental health tech.
The next step is to connect this frontend to a secure backend API, persist the data, and build out a dashboard for users to review their past thought records.
Resources
- React Docs: Managing State
- XState: JavaScript State Machines and Statecharts
- PostgreSQL: Official Documentation
- MongoDB: Official Documentation