All docs/Feature-Specific

docs/architecture/flowconfig-data-model-refactor.md

Hello App State Architecture

Overview

This document describes the state management architecture for the Hello app's onboarding flow. The architecture uses a unified DraftStore that holds the OnboardingDraft as the single source of truth, matching the backend data structure exactly.

Status: Implemented (January 2026)
Affected Apps: apps/hello (all onboarding steps)
Key Package: @repo/app-hello/web

Core Principle: OnboardingDraft is the Source of Truth

The fundamental principle of this architecture:

The frontend store structure mirrors the backend database structure exactly.

This means:

  • OnboardingDraft in the store = OnboardingDraft in Redis
  • No transformations or derived states stored
  • FlowConfig (for preview) is computed on-demand, never stored
  • All saves go through a single saveDraftAction(draftId, update) call

Data Model

OnboardingDraft Structure (Single Source of Truth)

interface OnboardingDraft {
  // Metadata
  id: string;
  currentStep: DraftStep;
  createdAt: number;
  updatedAt: number;
  
  // Organization data (from website extraction)
  org: {
    profile: DraftOrgProfile | null;  // name, description, website, etc.
    logoUrl: string | null;
    availableUsps: string[];
  };
  
  // Kickoff data (user inputs from plan-interview)
  kickoff: {
    website: string;
    objective: string;
    selectedUsps: string[];
    targetAudience: string;
    customNotes: string;
  };
  
  // Flow configuration (design, questions, rewards)
  flow: {
    name: string;
    supportedLanguages: Language[];
    questions: DraftQuestion[];        // Array with language keys + metadata
    rewards: DraftReward[];
    rewardsEnabled: boolean;
    branding: DraftBranding;           // colors, fonts, logo, backgrounds
  };
  
  // AI-generated content (translations, etc.)
  generated: {
    welcomeTitle: Record<Language, string>;
    welcomeSubtitle: Record<Language, string>;
    tipsTitle: Record<Language, string>;
    tipsDescriptions: Array<Record<Language, string>>;
    rewardsTitle: Record<Language, string>;
    rewardsSubtitle: Record<Language, string>;
    confirmTitle: Record<Language, string>;
    confirmCustomFields: DraftCustomField[];
  };
  
  // Publish state
  publish: {
    flowId: string | null;
    flowUrl: string | null;
    publishedAt: number | null;
  };
}

FlowConfig (Derived for Preview)

FlowConfig is never stored - it's computed when needed for the preview:

// Derived on-demand via hook
const flowConfig = useDraftFlowConfig();

The useDraftFlowConfig hook uses buildFlowConfigFromDraft(draft) internally, combining draft.flow, draft.generated, and draft.org into the FlowConfig format that the video flow preview component expects.

Live Preview Architecture

The live preview (ContextualFlowPreview) reads directly from the DraftStore via hooks. This ensures immediate updates when the user makes changes in any tab.

How It Works

// ContextualFlowPreview.tsx - reads from store directly
export const ContextualFlowPreview = memo(
  forwardRef<ContextualFlowPreviewController>(function ContextualFlowPreview(_props, ref) {
    // Read from store via hooks (single source of truth)
    const flowConfig = useDraftFlowConfig();
    const activeTab = useDraftStore((s) => s.activeTab);
    const editingLanguage = useDraftStore((s) => s.editingLanguage);

    // When flowConfig changes, reload the preview
    useEffect(() => {
      if (flowConfig && controllerRef.current) {
        controllerRef.current.loadFlow(flowConfig.id, flowConfig, targetScreen);
      }
    }, [flowConfig, targetScreen]);

    // ... render VideoFlowScreen
  })
);

Key points:

  • ContextualFlowPreview takes no props related to config/state
  • All data comes from DraftStore hooks
  • When any tab updates the store, the preview automatically re-renders

Why Hooks Instead of Props?

The preview component uses hooks directly rather than receiving props through SplitScreenBuilder. This eliminates prop drilling and ensures immediate reactivity:

// ✅ NEW: Components use hooks directly
<SplitScreenBuilder showPreview>
  <StudioStep />
</SplitScreenBuilder>
// ContextualFlowPreview reads from store internally

// ❌ OLD: Props passed through layers (removed)
<SplitScreenBuilder
  flowConfig={flowConfig}      // Removed
  activeTab={activeTab}        // Removed
  editingLanguage={language}   // Removed
>

SplitScreenBuilder

SplitScreenBuilder is a layout component that:

  • Handles split-panel layout (config panel + preview panel)
  • Provides panel width presets
  • Loads fonts via useDraftDesign() hook
interface SplitScreenBuilderProps {
  children: ReactNode;
  showPreview?: boolean;    // Whether to show preview panel
  progress?: number;        // Progress bar value
  showProgressBar?: boolean;
  headerSlot?: ReactNode;   // Header content (e.g., org switcher)
  onBack?: () => void;
  isBackDisabled?: boolean;
}

Note: No flowConfig, activeTab, or editingLanguage props - the preview reads these from the store directly.

Data Flow for Preview Updates

Read Priority: Source of Truth First

When hooks read values that exist in both the draft and derived FlowConfig, always read from the source of truth first (the draft), then fall back to derived data:

// useDraftDesign hook - correct read priority
return {
  // ✅ Read branding (source) first, then theme (derived) as fallback
  primaryColor: branding?.primaryColor || theme?.brand.colors.primary || "#ff6bd6",
  secondaryColor: branding?.secondaryColor || theme?.brand.colors.secondary || "#050a1f",
  fontFamily: branding?.fontFamily || theme?.brand.typography?.fontFamily,
  // ...
};

Why this matters:

  • branding = draft.flow.branding — updates immediately when user changes a value
  • theme = flowConfig.theme — derived from draft, may have reactivity lag

If you read theme first, you might return stale values when the user is actively editing.

Architecture Diagram

Data Flow

File Structure

packages/app-hello/src/web/
├── stores/
│   ├── draft-store.ts              # Zustand store holding OnboardingDraft
│   ├── draft-hooks.ts              # Convenience hooks (useDraftWelcome, useDraftFlowConfig, etc.)
│   ├── build-flow-config-from-draft.ts  # FlowConfig derivation
│   └── index.ts                    # Exports
├── types/
│   └── localized-content.ts        # ConfigTab type, etc.
├── components/
│   ├── ContextualFlowPreview.tsx   # Live preview (reads from store via hooks)
│   ├── SplitScreenBuilder.tsx      # Layout component (no config props)
│   └── config-tabs/                # Tab components using hooks
└── hooks/
    └── useFontLoader.ts            # Font loading for preview

apps/hello/
├── components/steps/
│   ├── StudioStepForm.tsx          # Uses DraftStore
│   ├── CompanyStepForm.tsx         # Uses DraftStore
│   ├── WebsiteStepForm.tsx         # Uses DraftStore
│   └── ObjectiveStepForm.tsx       # Uses DraftStore
└── hooks/
    └── useDraftPersistence.ts      # Debounced persistence hook

Usage Guide

Initializing the Store

import { useDraftStore } from "@repo/app-hello/web";

function StudioStepForm({ initialDraft, draftId }) {
  // Initialize SYNCHRONOUSLY to avoid blank preview
  useState(() => {
    const store = useDraftStore.getState();
    if (store.draftId !== draftId) {
      store.initialize(initialDraft, draftId);
    }
  });
}

Using Convenience Hooks in Tabs

import { useDraftWelcome } from "@repo/app-hello/web";

function WelcomeTab() {
  const {
    title,
    subtitle,
    currentLanguage,
    defaultLanguage,
    updateTitle,
    updateSubtitle,
  } = useDraftWelcome();

  return (
    <BlurSaveInput
      value={title}
      onSave={updateTitle}
      placeholder={currentLanguage !== defaultLanguage 
        ? `Translate: "${title}"` 
        : "Enter title..."}
    />
  );
}

Getting FlowConfig for Preview

The ContextualFlowPreview component handles this automatically - it reads from the store via hooks. You don't need to pass any config props:

import { SplitScreenBuilder } from "@repo/app-hello/web";

function StudioStepForm() {
  return (
    <SplitScreenBuilder showPreview>
      {/* ContextualFlowPreview reads from store internally */}
      <StudioStep onContinue={...} />
    </SplitScreenBuilder>
  );
}

If you need to access FlowConfig directly in custom components:

import { useDraftFlowConfig } from "@repo/app-hello/web";

function CustomPreviewComponent() {
  // Computed from draft, never stored
  const flowConfig = useDraftFlowConfig();

  if (!flowConfig) return null;
  // Use flowConfig as needed...
}

Setting Up Persistence

import { useDraftPersistence } from "@/hooks/useDraftPersistence";

function StudioStepForm({ draftId, saveDraftAction }) {
  const { flushChanges } = useDraftPersistence({
    draftId,
    saveDraftAction,
    onError: handleError,
    debounceMs: 300,
  });
  
  // flushChanges() forces immediate save (e.g., before navigation)
}

Updating Organization Data (CompanyStep)

import { useDraftStore } from "@repo/app-hello/web";

function CompanyStepForm() {
  const updateOrg = useDraftStore((s) => s.updateOrg);
  const updateKickoff = useDraftStore((s) => s.updateKickoff);
  
  const handleCompanyNameChange = (name: string) => {
    updateOrg({ profile: { name } });
  };
  
  const handleTargetAudienceChange = (targetAudience: string) => {
    updateKickoff({ targetAudience });
  };
}

Why This Architecture?

Benefits

  1. Single Source of Truth

    • No sync bugs between multiple state representations
    • Store structure = Database structure
    • Easy to reason about
  2. Simpler Persistence

    • One saveDraftAction call with accumulated changes
    • No complex transformation logic
    • Debounced automatically
  3. Consistent Pattern

    • All onboarding steps use the same pattern
    • Same hooks, same store, same persistence
  4. FlowConfig is Derived

    • Computed on-demand for preview
    • Never stored, never out of sync
    • Uses existing buildFlowConfigFromDraft function
  5. Local UI Updates Work

    • User edits update store immediately
    • Preview reflects changes instantly
    • No "edit but not see" bugs

Previous Architecture (Deprecated)

The previous architecture had two problems:

  1. FlowConfigStore held a transformed FlowConfig object
  2. LocalizedContent was a separate derived state that had to be synced

This caused:

  • Sync bugs when one was updated but not the other
  • Complex bidirectional mapping code
  • Local state not updating (the bug that triggered this refactor)

Store API Reference

State

interface DraftState {
  draft: OnboardingDraft | null;
  draftId: string | null;
  isDirty: boolean;
  pendingUpdate: DraftUpdate;
  _saveGeneration: number;      // Incremented on every updateDraft — prevents auto-save race conditions
  editingLanguage: Language;
  activeTab: ConfigTab;
}

Actions

interface DraftActions {
  // Initialization
  initialize(draft: OnboardingDraft, draftId: string): void;
  reset(): void;

  // Generic update (accumulates into pendingUpdate)
  updateDraft(update: DraftUpdate): void;

  // Section-specific convenience actions
  updateOrg(updates: Partial<DraftOrg>): void;
  updateKickoff(updates: Partial<DraftKickoff>): void;
  updateFlow(updates: Partial<DraftFlow>): void;
  updateGenerated(updates: Partial<DraftGenerated>): void;

  // Granular updates for StudioStep
  updateWelcomeTitle(lang: Language, value: string): void;
  updateQuestion(index: number, lang: Language, text: string): void;
  addQuestion(text?: string): void;
  removeQuestion(index: number): void;
  appendQuestions(newQuestions: DraftQuestion[]): void;
  // ... etc.

  // Translation (atomic multi-field update)
  applyFullLanguageTranslation(
    lang: Language,
    genUpdate: Partial<DraftGenerated>,
    questions?: DraftQuestion[],
    rewards?: DraftReward[],
  ): void;

  // Background image operations
  addBackgroundImage(url: string): void;
  removeBackgroundImage(index: number): void;
  reorderBackgroundImages(fromIndex: number, toIndex: number): void;

  // Persistence (generation-aware)
  markClean(generation?: number): void;  // Skips if _saveGeneration changed
  getPendingUpdate(): DraftUpdate;
}

Convenience Hooks

HookUsed ByReturns
useDraftFlowConfigContextualFlowPreviewFlowConfig (derived from draft) or null
useDraftSupportedLanguagesLanguage componentsLanguage[] (from draft.flow.supportedLanguages)
useDraftOrgProfilePlanInterviewStepcompanyName, companyDescription, targetAudience, USPs, updateCompanyName, etc.
useDraftWelcomeWelcomeTabtitle, subtitle, updateTitle, updateSubtitle
useDraftTipsTipsTabtitle, descriptions, addTip, removeTip
useDraftQuestionsQuestionsTabquestions, addQuestion, updateQuestion
useDraftRewardsRewardsTabitems, addReward, updateRewardItem
useDraftConfirmConfirmTabtitle, customFields, addCustomField
useDraftDesignDesignTab, SplitScreencolors, fonts, assets, updateDesign
useDraftSettingsSettingsTabflowName, rewardsEnabled, updateSettings
useDraftLanguageManagementLanguageManagerlanguages, addLanguage, removeLanguage
useDraftTabNavigationTab componentsactiveTab, setActiveTab

Testing

import { useDraftStore } from "@repo/app-hello/web";

beforeEach(() => {
  useDraftStore.getState().reset();
});

test("updates welcome title and marks dirty", () => {
  const store = useDraftStore.getState();
  
  store.initialize(mockDraft, "test-draft");
  store.updateWelcomeTitle("en", "New Title");
  
  expect(store.draft?.generated.welcomeTitle.en).toBe("New Title");
  expect(store.isDirty).toBe(true);
  expect(store.pendingUpdate.generated?.welcomeTitle?.en).toBe("New Title");
});

Extraction Data Flow (Organization Data)

This section documents the complete data flow for organization data extracted from websites via LLM, which is a special case that requires careful handling.

The Challenge

Organization data (company name, description, USPs, etc.) has a unique lifecycle:

  1. Extracted by LLM from website scraping
  2. Persisted to backend as part of OnboardingDraft
  3. Displayed in frontend for user review/editing
  4. Editable by user who may override extracted values
  5. Must stay in sync between display and persistence

Data Flow Diagram

Key Principle: Store is Single Source of Truth

The DraftStore holds OnboardingDraft which includes extracted organization data:

// Backend extracts and saves to:
draft.org.profile = {
  name: "Acme Corp",           // From LLM
  description: "...",          // From LLM
  website: "https://acme.com", // From user input
  // ...
};
draft.org.availableUsps = ["Fast shipping", "24/7 support"];  // From LLM
draft.org.logoUrl = "https://...";  // From scraping

The UI must read from the store, not from server props:

// ✅ CORRECT: Read from store
const companyName = useDraftStore((s) => s.draft?.org.profile?.name) ?? "";

// ❌ WRONG: Read from server props (stale after first render)
const companyName = organizationData?.extraction.name ?? "";

Implementation Pattern: Use Hooks

Components displaying editable extracted data should use the useDraftOrgProfile hook, following the same pattern as useDraftWelcome, useDraftTips, etc.:

// In PlanInterviewStep (or any component needing org profile data)
import { useDraftOrgProfile } from "@repo/app-hello/web";

function PlanInterviewStep({ organizationData }) {
  // Hook reads from store, provides update functions
  const {
    companyName,
    companyDescription,
    targetAudience,
    selectedUsps,
    availableUsps,
    customNotes,
    updateCompanyName,
    updateCompanyDescription,
    updateTargetAudience,
    updateSelectedUsps,
    updateAvailableUsps,
    updateCustomNotes,
  } = useDraftOrgProfile();

  // Use store values with fallback for initial load
  const displayName = companyName || organizationData?.extraction.name || "";
  const displayDescription = companyDescription || organizationData?.extraction.description || "";

  // Update functions automatically preserve other fields
  const handleNameBlur = (value: string) => updateCompanyName(value);
  const handleDescriptionBlur = (value: string) => updateCompanyDescription(value);
}

The hook:

  • Reads from the store (draft.org.profile, draft.kickoff)
  • Provides update functions that preserve other fields (spread current profile)
  • Follows the same pattern as other tab hooks (useDraftWelcome, etc.)

Updating One Field Without Clobbering Others

Best approach: Use the hook's update functions - they handle this automatically:

// ✅ BEST: Hook handles preserving other fields internally
const { updateCompanyDescription } = useDraftOrgProfile();
updateCompanyDescription(newDescription);  // Safe - preserves other fields

If writing manual updates, read OTHER fields from the current store state:

// ✅ CORRECT: Read other fields from store
const handleDescriptionChange = (newDescription: string) => {
  const currentProfile = useDraftStore.getState().draft?.org.profile;
  updateOrg({
    profile: {
      ...currentProfile,  // Preserve all existing values
      description: newDescription,
    },
  });
};

// ❌ WRONG: Read other fields from stale server props
const handleDescriptionChange = (newDescription: string) => {
  updateOrg({
    profile: {
      name: organizationData?.extraction.name,  // STALE!
      description: newDescription,
    },
  });
};

organizationData vs Store: When to Use What

Data TypeSourceReason
Editable fields (name, description, target audience)Store (draft.org.profile.*)User may have edited
USP poolStore (draft.org.availableUsps)User may have added/removed
Selected USPsStore (draft.kickoff.selectedUsps)User selection
Non-editable metadata (logo URL, branding colors)Either (same value)Not user-editable
Extraction statusServer propsStatic after load

Common Pitfalls

  1. Reading from server props for editable fields - Props are stale after first render
  2. Resetting other fields when updating one - Always spread current store values
  3. Not passing initial values from store - Child components need store values as props
  4. Double initialization - Only initialize store if draftId changes

Related Documentation

Changelog

February 2026 - Store-Centric Data Flow Refactor

  • applyFullLanguageTranslation replaces applyLanguageTranslations — applies genUpdate + questions + rewards in one atomic store update
  • appendQuestions — store method for adding generated questions (replaces inline array spread in components)
  • addBackgroundImage / removeBackgroundImage / reorderBackgroundImages — store methods for background image operations (moved from inline component logic)
  • Generation-aware markClean(generation?)_saveGeneration counter prevents auto-save race conditions
  • buildFullLanguageTranslationUpdate — registry function that combines genUpdate + question mapping + reward mapping in one call
  • extractQuestionTexts — registry utility for question generation context
  • FlowEditClient now applies translation results to store directly — no data returned to FlowStudioEditor
  • All data manipulation moved from components to store methods or registry utilities

January 2026 - Live Preview Refactor

  • ContextualFlowPreview now reads from store via hooks (no props)
  • Removed flowConfig, activeTab, editingLanguage props from SplitScreenBuilder
  • Added useDraftFlowConfig hook for FlowConfig derivation
  • Preview updates immediately when any store value changes
  • Fixed design tab colors not syncing to live preview

January 2026 - DraftStore Architecture

  • Replaced FlowConfigStore with unified DraftStore
  • Removed LocalizedContent layer - draft is stored as-is
  • FlowConfig now derived via buildFlowConfigFromDraft
  • Single useDraftPersistence hook replaces section-specific saves
  • All onboarding steps now use consistent DraftStore pattern
  • Fixed local state not updating bug in CompanyStep
  • Added useDraftOrgProfile hook for PlanInterviewStep