Back to blog
May 2, 2026

From Phase State to React Router: A Migration Story

ReactReact RouterBunViteArchitecture

How we migrated from phase-based state management to React Router with deep linking in a Bun+Vite monorepo

From Phase State to React Router: A Migration Story

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 on

This 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 chaosfable_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                 → DiscoverPage

Implementation: 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/abc123

This 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 sizereact-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 conditions

2. Routes = Better UX

Users expect:

  • Deep links that work (/story/abc123)
  • Browser back button that works
  • Refresh that doesn't lose state
  • 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/_