From Phase State to React Router: A Migration Story
How we migrated from phase-based state management to React Router with deep linking in a Bun+Vite monorepo

The Problem
Fable's UI was a 438-line App.tsx that managed everything through a single phase state variable:
// ui/src/App.tsx (BEFORE)
type Phase = "idle" | "collecting_info" | "writing" | "post_processing" | "complete" | "discover";
const [phase, setPhase] = useState<Phase>("idle");
// ... 438 lines of conditional rendering
{phase === "idle" && <LandingPage ... />}
{phase === "collecting_info" && <StorySetup ... />}
{phase === "writing" && <StoryBook ... />}
// ... and so onThis worked for a while. But as we added features, the problems became unbearable:
1. No deep linking — You couldn't share a story URL. Every link went to /.
2. No browser history — The back button didn't work. Users got stuck.
3. State management chaos — fable_phase and fable_session_id were manually synced to localStorage.
4. Unreadable App.tsx — 438 lines of switch-case rendering is a maintenance nightmare.
The Solution: React Router v7
We decided to migrate to React Router v7 with proper routes, nested layouts, and route-based state management.
Target Route Structure
/ → LandingPage (idle)
/setup → StorySetup (collecting_info)
/write/:sessionId → StoryBook (writing)
/story/:storyId → StoryComplete (complete)
/discover → DiscoverPageImplementation: Phase by Phase
Phase 1: Route Setup & Layout
First, we created a Layout component with a shared Header and main content area:
// ui/src/components/Layout.tsx
import { Outlet } from "react-router-dom";
import Header from "./Header";
export default function Layout() {
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
<Header />
<main className="container mx-auto px-4 py-8">
<Outlet /> {/* Child routes render here */}
</main>
</div>
);
}Then, we rewrote App.tsx to use BrowserRouter and Routes:
// ui/src/App.tsx (AFTER)
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import LandingPage from "./components/LandingPage";
import StorySetup from "./components/StorySetup";
import StoryBook from "./components/StoryBook";
import StoryComplete from "./components/StoryComplete";
import DiscoverPage from "./components/DiscoverPage";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
<Route path="/setup" element={<StorySetup />} />
<Route path="/write/:sessionId" element={<StoryBook />} />
<Route path="/story/:storyId" element={<StoryComplete />} />
<Route path="/discover" element={<DiscoverPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}Phase 2: Route-Specific Refactoring
Each component now uses useNavigate() and useParams() instead of relying on a global phase state:
// ui/src/components/StorySetup.tsx
import { useNavigate } from "react-router-dom";
export default function StorySetup() {
const navigate = useNavigate();
const handleSubmit = async (data: SetupData) => {
const response = await apiCall("/api/setup", data);
const { sessionId } = response;
// Navigate to writing phase with sessionId in URL
navigate(`/write/${sessionId}`);
};
return (
<div>
{/* Form fields */}
<button onClick={() => navigate("/")}>Back</button>
</div>
);
}// ui/src/components/StoryBook.tsx
import { useParams } from "react-router-dom";
export default function StoryBook() {
const { sessionId } = useParams<{ sessionId: string }>();
// Restore messages from localStorage keyed by sessionId
const messages = JSON.parse(
localStorage.getItem(`fable_messages_${sessionId}`) || "[]",
);
return (
<div>
{/* Chat interface */}
<button onClick={() => navigate("/")}>Back to Library</button>
</div>
);
}Phase 3: State Management & Persistence
We removed the phase state entirely and relied on route params + localStorage:
// Persistence strategy
const PERSISTENCE_KEYS = {
messages: (sessionId: string) => `fable_messages_${sessionId}`,
setup: (sessionId: string) => `fable_setup_${sessionId}`,
story: (storyId: string) => `fable_story_${storyId}`,
};
// In StoryBook component
useEffect(() => {
const savedMessages = localStorage.getItem(
PERSISTENCE_KEYS.messages(sessionId),
);
if (savedMessages) {
setMessages(JSON.parse(savedMessages));
}
}, [sessionId]);Phase 4: Post-Processing Recovery
One of the coolest features: if a user refreshes during post-processing, the app recovers automatically:
// ui/src/components/StoryBook.tsx
useEffect(() => {
// Check if session is in post_processing state
fetch(`/api/progress?sessionId=${sessionId}`)
.then((res) => res.json())
.then((data) => {
if (data.status === "post_processing") {
// Poll every 2 seconds
const interval = setInterval(async () => {
const progress = await fetch(
`/api/progress?sessionId=${sessionId}`,
).then((r) => r.json());
setProgress(progress);
if (progress.status === "complete") {
clearInterval(interval);
navigate(`/story/${progress.storyId}`);
}
}, 2000);
return () => clearInterval(interval);
}
});
}, [sessionId]);Key Wins
1. Deep Linking
You can now share story URLs directly:
https://fable.example.com/story/abc123This was impossible with phase-based routing — every link went to / and the app had to "figure out" what state to restore.
2. Browser History Works
The back button now works naturally. Each route is a history entry:
// Navigate to setup
navigate("/setup");
// User clicks back → goes to "/" (LandingPage)
// No more manual state resets!3. Code Organization
Each route is explicit in its own component. No more 438-line App.tsx with conditional rendering.
| Before | After |
| ----------------------------- | ------------------------ |
| App.tsx (438 lines) | App.tsx (50 lines) |
| Phase conditionals everywhere | Clean route definitions |
| Manual localStorage sync | Route params + API fetch |
4. Refresh Resilience
Route params + API fetch = always recoverable:
// StoryComplete.tsx
export default function StoryComplete() {
const { storyId } = useParams<{ storyId: string }>();
const [story, setStory] = useState<StoryData | null>(null);
useEffect(() => {
// Fetch from API (source of truth)
fetch(`/api/stories/${storyId}`)
.then((res) => res.json())
.then((data) => setStory(data.story));
}, [storyId]);
if (!story) return <div>Loading...</div>;
return <div>{story.title}...</div>;
}5. Per-Route Analytics
Now that we have explicit routes, adding analytics is trivial:
// In each route component
useEffect(() => {
analytics.trackPageView(window.location.pathname);
}, []);Trade-offs
Pros
1. Deep linking — Share /story/abc123 with friends
2. Browser history — Back/forward buttons work naturally
3. Code organization — Each route is explicit
4. Scalability — Adding new routes is trivial
5. State isolation — Each route manages its own data
6. SEO — Each route can have its own meta tags
Cons
1. Bundle size — react-router-dom adds ~15KB gzipped (already installed)
2. Complexity — More files, more concepts for new developers
3. State sync — Need to sync route params with fetched data
4. Migration effort — One-time refactor of App.tsx
Lessons Learned
1. Phase State is a Trap
If your app has more than 3 "phases", use routes. Phase state seems simpler initially, but becomes unmanageable as you add features.
// ❌ When this starts growing, stop and use routes
const [phase, setPhase] = useState("idle");
if (phase === "step1") ...
if (phase === "step2") ...
// ... 20 more conditions2. Routes = Better UX
Users expect:
/story/abc123)Routes give you all three for free.
3. Layout Components are Powerful
The <Outlet /> pattern lets you share layout across routes:
<Route element={<Layout />}>
<Route path="/" element={<LandingPage />} />
<Route path="/setup" element={<StorySetup />} />
{/* All routes share the Header from Layout */}
</Route>4. Persistence Strategy Matters
With routes, you need a clear persistence strategy:
| Data | Where to store |
| ------------ | --------------------------------- |
| Route params | URL (sessionId, storyId) |
| Session data | localStorage (keyed by sessionId) |
| Fetched data | API + cache in localStorage |
5. Migration Doesn't Have to Be All-at-Once
We used a phased approach:
1. Install React Router
2. Create Layout component
3. Rewrite App.tsx with Router
4. Update one component at a time
5. Test all routes
6. Clean up dead code
The Takeaway
Phase-based state management is fine for prototypes. But for production apps with multiple "screens", routes are the answer.
The migration took a day. The benefits — deep linking, browser history, clean code — lasted forever.
_Check more open source repos at: github.com/mnkrana/_