Skip to main content

State Management Patterns

Status: ✅ Active
Version: 4.0 (Controlled Components + Duplicate Prevention)
Last Updated: 2025-11-21
Added: Pattern 2 (Duplicate Prevention for Pickers)

Philosophy

Just like our Design System ensures UI consistency, our State Management Patterns ensure data consistency across all flows and components. Core Principle: Single Source of Truth

Pattern 1: Controlled Components

Industry Standard

This pattern is used by:
  • React Hook Form - Form state management
  • Shopify Polaris - Enterprise component library
  • Linear - Issue tracking app
  • Notion - Document editing
  • Formik - Form state library

How It Works

// ✅ CORRECT: Controlled Component Pattern
<ContentTypeComponent
  step={currentStep}
  initialValue={restoredValue}        // ← Single source of truth
  onValueChange={handleValueChange}   // ← Notify parent of changes
/>
// ❌ WRONG: Uncontrolled (each component manages its own state)
<ContentTypeComponent
  step={currentStep}
  onValueChange={handleValueChange}
/>
// Component internally fetches session.responses[step.id] ← Inconsistent!

Implementation

FlowContainer (Orchestrator)

Responsibilities:
  1. Restore saved responses from session
  2. Pass restored data as initialValue to content types
  3. Receive updates via onValueChange
  4. Persist to session storage
  5. Manage navigation
Code:
// Restore saved response when step changes
useEffect(() => {
  const savedResponse = session?.responses[currentStep.id];
  if (savedResponse) {
    const restoredValue = extractValueFromResponse(savedResponse);
    setCurrentValue(restoredValue);
    setIsValid(validateResponse(savedResponse));
  } else {
    setCurrentValue(null);
    setIsValid(false);
  }
}, [currentStep?.id, session]);

// Render content type with restored data
<ContentTypeComponent
  step={currentStep}
  initialValue={currentValue}         // ← Pass restored data
  onValueChange={handleValueChange}   // ← Receive updates
/>

Content Type Components

Responsibilities:
  1. Render UI
  2. Accept initialValue prop
  3. Manage internal UI state (e.g., slider position, text input)
  4. Call onValueChange(value, isValid) when data changes
  5. DO NOT directly access session or AsyncStorage
Interface (all content types must implement):
export interface ContentTypeProps<TStep, TValue> {
  step: TStep;
  initialValue?: TValue | null;          // ← Restored data from FlowContainer
  onValueChange: (value: TValue, isValid: boolean) => void;
  onAutoAdvance?: () => void;            // Optional (breathing, etc.)
}
Example: PickerType
export const PickerType: React.FC<PickerTypeProps> = ({ 
  step, 
  initialValue,      // ← Receive from FlowContainer
  onValueChange 
}) => {
  const [selections, setSelections] = useState<PickerSelection[]>([]);
  
  // Initialize from initialValue (on mount or when initialValue changes)
  useEffect(() => {
    if (initialValue && Array.isArray(initialValue)) {
      setSelections(initialValue);
    } else {
      setSelections([]);
    }
  }, [initialValue]);  // ← Key dependency
  
  // Notify parent when selections change
  useEffect(() => {
    const isValid = selections.length >= step.minSelections;
    onValueChange(selections, isValid);
  }, [selections, onValueChange]);
  
  // DO NOT access session.responses directly ❌
  // DO NOT restore from AsyncStorage directly ❌
};

Content Type Reference

Picker Steps (PickerType.tsx)

initialValue type: PickerSelection[]
interface PickerSelection {
  id: string;
  label: string;
  intensity?: number;
  custom?: boolean;
}
Example:
initialValue={[
  { id: "joy", label: "Joy", intensity: 7 }
]}

Text Prompt Steps (TextPromptType.tsx)

initialValue type: string | Record<string, string>
// Single input
initialValue="My journal entry text"

// Multi-input
initialValue={{
  "field1": "Response 1",
  "field2": "Response 2"
}}

Scripture Steps (ScriptureType.tsx)

initialValue type: { selected: ScriptureVerse, userParaphrase?: string }
initialValue={{
  selected: { reference: "John 3:16", text: "..." },
  userParaphrase: "God loves me"
}}

Breathing Steps (BreathingType.tsx)

initialValue type: boolean
initialValue={true}  // Completed

Benefits

1. Predictable Data Flow

User interacts → Component updates internal state

        onValueChange(value, isValid)

      FlowContainer updates currentValue

      Persisted to session.responses

      Navigate away and back

    FlowContainer restores currentValue

      Pass as initialValue to component

    Component displays restored data ✅

2. Consistent Navigation

Forward navigation:
  • Save response to session
  • Reset currentValue to null
  • Navigate to next step
Backward navigation:
  • Navigate to previous step
  • FlowContainer restores currentValue from session
  • Component receives via initialValue
  • Data appears ✅

3. Single Source of Truth

  • OLD: 5 different components, 5 different restoration patterns
  • NEW: 1 pattern, used consistently everywhere

4. Easy to Debug

// All state visible in FlowContainer
console.log("Current value:", currentValue);
console.log("Is valid:", isValid);
console.log("Saved responses:", session.responses);

5. Testable

// Test content type as controlled component
<PickerType
  step={mockStep}
  initialValue={mockSelections}
  onValueChange={mockCallback}
/>

Migration Checklist

When updating a content type component to this pattern:
  • Add initialValue prop to component interface
  • Remove direct session access
  • Remove useFlow() hook (except for special cases)
  • Add useEffect to initialize from initialValue
  • Ensure onValueChange is called when data changes
  • Update FlowContainer to pass initialValue
  • Test backward/forward navigation

Common Pitfalls

❌ DON’T: Access session directly in content types

// BAD
const { session } = useFlow();
const savedResponse = session?.responses[step.id];

✅ DO: Receive via initialValue

// GOOD
const [value, setValue] = useState(initialValue || defaultValue);

❌ DON’T: Call onValueChange in render

// BAD - causes infinite loops
const value = calculateValue();
onValueChange(value, true);  // ← Called every render!

✅ DO: Call onValueChange in useEffect

// GOOD
useEffect(() => {
  const value = calculateValue();
  onValueChange(value, isValid);
}, [dependencies]);

❌ DON’T: Forget initialValue in dependencies

// BAD - won't update when navigating back
useEffect(() => {
  setValue(initialValue);
}, []);  // ← Missing initialValue!

✅ DO: Include initialValue in dependencies

// GOOD
useEffect(() => {
  setValue(initialValue || defaultValue);
}, [initialValue]);  // ← Will update when restored

Testing

Unit Test (Content Type)

it("restores initialValue when provided", () => {
  const { rerender } = render(
    <PickerType
      step={mockStep}
      initialValue={null}
      onValueChange={mockOnChange}
    />
  );
  
  // Should be empty initially
  expect(screen.queryByText("Selected")).toBeNull();
  
  // Update initialValue (simulating navigation back)
  rerender(
    <PickerType
      step={mockStep}
      initialValue={[{ id: "joy", label: "Joy" }]}
      onValueChange={mockOnChange}
    />
  );
  
  // Should display restored value
  expect(screen.getByText("Joy")).toBeInTheDocument();
});

Integration Test (FlowContainer)

it("preserves data when navigating back", () => {
  render(<FlowContainer template={mockTemplate} />);
  
  // Step 1: Select emotion
  fireEvent.press(screen.getByText("Joy"));
  fireEvent.press(screen.getByText("Continue"));
  
  // Step 2: Go back
  fireEvent.press(screen.getByText("Back"));
  
  // Step 1: Should still show "Joy" selected
  expect(screen.getByText("Joy")).toHaveStyle({ /* selected styles */ });
});

Pattern 2: Duplicate Prevention (Pickers)

Problem

When building multi-select pickers, users can accidentally select the same item multiple times:
// ❌ BAD: Shows all emotions, even if already selected
<EmotionPicker options={allEmotions} onSelect={handleSelect} />
// User selects "Joy" twice → Duplicate in list

Solution

Always filter out selected options before rendering:
// ✅ GOOD: Filter selected emotions from picker
const availableEmotions = useMemo(
  () => filterSelectedOptions(allEmotions, selectedEmotionIds),
  [selectedEmotionIds]
);

<EmotionPicker options={availableEmotions} onSelect={handleSelect} />

Utilities (src/utils/pickerUtils.ts)

Generic option filtering functions:
// Filter out selected options
filterSelectedOptions<T>(allOptions: T[], selectedIds: string[]): T[]

// Extract IDs from complex state
getSelectedIds<T>(selections: T[], idKey: keyof T = 'id'): string[]

// Combined utility
filterBySelectedState<T, S>(allOptions: T[], selections: S[], idKey: keyof S): T[]

Implementation Steps

Step 1: Import Utilities

import { filterSelectedOptions, getSelectedIds } from '@/utils/pickerUtils';

Step 2: Compute Available Options

// Extract IDs from state
const selectedIds = getSelectedIds(emotionStates, 'primaryId');

// Filter options using useMemo
const availableOptions = useMemo(
  () => filterSelectedOptions(allEmotions, selectedIds),
  [emotionStates]
);

Step 3: Render Picker

<Picker options={availableOptions} onSelect={handleSelect} />

Real-World Example: Emotion Selector

File: src/flow-engine/components/EmotionSelector.tsx
// State: List of selected emotions
const [emotionStates, setEmotionStates] = useState<EmotionState[]>([]);

// Pattern: Duplicate Prevention
const availablePrimaryEmotions = useMemo(() => {
  const selectedPrimaryIds = getSelectedIds(emotionStates, 'primaryId');
  return filterSelectedOptions(primaryEmotions, selectedPrimaryIds);
}, [emotionStates]);

// Render picker with filtered options
<HorizontalEmotionPicker
  options={availablePrimaryEmotions}
  onSelect={handlePrimarySelect}
/>
Result:
  • ✅ Select “Joy” → “Joy” disappears from picker
  • ✅ Select “Sadness” → Only 4 emotions remain
  • ✅ Max 3 selections → Picker hidden

Best Practices

✅ DO:
  1. Always use useMemo for filtering (performance)
  2. Extract IDs before filtering (clear intent)
  3. Use generic utilities (don’t rewrite logic)
❌ DON’T:
  1. Don’t filter inside render (causes re-renders)
  2. Don’t duplicate filtering logic (use utilities)
  3. Don’t allow duplicates (always filter)

Testing Checklist

  • Select option → Option disappears from picker
  • Select max options → Picker hidden/disabled
  • Remove option → Option reappears in picker
  • Navigate back → State persists correctly
  • No duplicates in selected list
  • TypeScript: 0 errors
  • Performance: useMemo prevents unnecessary re-renders


Maintenance

When to update this doc:
  • Adding new state management pattern
  • Adding new content type
  • Discovering new edge cases
Review frequency: After major architecture changes
Maintained By: All contributors
Questions? Review implementations in:
  • src/flow-engine/content-types/ (Controlled Components)
  • src/flow-engine/components/EmotionSelector.tsx (Duplicate Prevention)
  • src/utils/pickerUtils.ts (Utility functions)