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:
OnboardingDraftin the store =OnboardingDraftin 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:
ContextualFlowPreviewtakes 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 valuetheme=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
-
Single Source of Truth
- No sync bugs between multiple state representations
- Store structure = Database structure
- Easy to reason about
-
Simpler Persistence
- One
saveDraftActioncall with accumulated changes - No complex transformation logic
- Debounced automatically
- One
-
Consistent Pattern
- All onboarding steps use the same pattern
- Same hooks, same store, same persistence
-
FlowConfig is Derived
- Computed on-demand for preview
- Never stored, never out of sync
- Uses existing
buildFlowConfigFromDraftfunction
-
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:
- FlowConfigStore held a transformed
FlowConfigobject - 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
| Hook | Used By | Returns |
|---|---|---|
useDraftFlowConfig | ContextualFlowPreview | FlowConfig (derived from draft) or null |
useDraftSupportedLanguages | Language components | Language[] (from draft.flow.supportedLanguages) |
useDraftOrgProfile | PlanInterviewStep | companyName, companyDescription, targetAudience, USPs, updateCompanyName, etc. |
useDraftWelcome | WelcomeTab | title, subtitle, updateTitle, updateSubtitle |
useDraftTips | TipsTab | title, descriptions, addTip, removeTip |
useDraftQuestions | QuestionsTab | questions, addQuestion, updateQuestion |
useDraftRewards | RewardsTab | items, addReward, updateRewardItem |
useDraftConfirm | ConfirmTab | title, customFields, addCustomField |
useDraftDesign | DesignTab, SplitScreen | colors, fonts, assets, updateDesign |
useDraftSettings | SettingsTab | flowName, rewardsEnabled, updateSettings |
useDraftLanguageManagement | LanguageManager | languages, addLanguage, removeLanguage |
useDraftTabNavigation | Tab components | activeTab, 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:
- Extracted by LLM from website scraping
- Persisted to backend as part of
OnboardingDraft - Displayed in frontend for user review/editing
- Editable by user who may override extracted values
- 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 Type | Source | Reason |
|---|---|---|
| Editable fields (name, description, target audience) | Store (draft.org.profile.*) | User may have edited |
| USP pool | Store (draft.org.availableUsps) | User may have added/removed |
| Selected USPs | Store (draft.kickoff.selectedUsps) | User selection |
| Non-editable metadata (logo URL, branding colors) | Either (same value) | Not user-editable |
| Extraction status | Server props | Static after load |
Common Pitfalls
- Reading from server props for editable fields - Props are stale after first render
- Resetting other fields when updating one - Always spread current store values
- Not passing initial values from store - Child components need store values as props
- Double initialization - Only initialize store if
draftIdchanges
Related Documentation
Changelog
February 2026 - Store-Centric Data Flow Refactor
applyFullLanguageTranslationreplacesapplyLanguageTranslations— applies genUpdate + questions + rewards in one atomic store updateappendQuestions— 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?)—_saveGenerationcounter prevents auto-save race conditions buildFullLanguageTranslationUpdate— registry function that combines genUpdate + question mapping + reward mapping in one callextractQuestionTexts— 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
ContextualFlowPreviewnow reads from store via hooks (no props)- Removed
flowConfig,activeTab,editingLanguageprops fromSplitScreenBuilder - Added
useDraftFlowConfighook 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
FlowConfigStorewith unifiedDraftStore - Removed
LocalizedContentlayer - draft is stored as-is FlowConfignow derived viabuildFlowConfigFromDraft- Single
useDraftPersistencehook replaces section-specific saves - All onboarding steps now use consistent DraftStore pattern
- Fixed local state not updating bug in CompanyStep
- Added
useDraftOrgProfilehook for PlanInterviewStep