Space to navigate
Developer Workshop

From Flutter/BloC to React Router

Building Web Apps with Familiar Patterns

Declarative UI Unidirectional Flow Event -> State -> UI

You already know the patterns. Today we translate them.

Today's Stack

Tools You'll Use

Layer React Stack Flutter Equivalent
Routing + Data React Router 7 GoRouter + BloC
Components React 19 Flutter Widgets
Styling Tailwind CSS Inline styles / Theme
UI Kit Shadcn/UI Material / Cupertino
Runtime Cloudflare Workers Firebase / Cloud Run

Pre-configured

Bundlers, TypeScript, Tailwind

You'll Build

Loaders, Actions, Components

Quick Check

Got Node.js?

Hopefully you've already set this up.
If not, here's your chance while I keep yapping.

# 1. Install Volta (recommended) https://docs.volta.sh/guide/getting-started # 2. Get latest Node volta install node # 3. Clone and install git clone https://github.com/flp-stephen/fe-workshop-template cd fe-workshop-template npm install
Node 20+ npm 10+
The Bridge

Four Mental Models

Flutter is "Widgets all the way down."
React is "Components all the way down."

#1 — The Tree

Widget tree → Component tree

#2 — Data Loading

BloC events → Loaders & Actions

#3 — Mutations

bloc.add() → <Form>

#4 — Nesting

ShellRoute child → <Outlet />

Mental Model #1

It's Still a Tree

Flutter

MaterialApp
Scaffold
AppBar
Column
Text
ListView
ListTile

React

<RouterProvider>
<RootLayout>
<Header />
<main>
<h1>
<ul>
<li>
No build(BuildContext) needed — just return JSX
Local State

StatefulWidget → useState

Flutter

class CounterWidget extends StatefulWidget { @override State createState() => _CounterState(); } class _CounterState extends State { int count = 0; @override Widget build(context) { return ElevatedButton( onPressed: () => setState(() => count++), child: Text('$count'), ); } }

React

function Counter() { const [count, setCount] = useState(0); return ( <Button onClick={() => setCount(count + 1)}> {count} </Button> ); }
No separate State class — useState hook lives inside the function
Mental Model #2

Events & States Are Now Request/Response

Flutter BloC

class LoadBookmarks extends BookmarkEvent {} on<LoadBookmarks>((event, emit) async { emit(BookmarkLoading()); final data = await repository.getAll(); emit(BookmarkLoaded(data)); }); // In widget: BlocBuilder<BookmarkBloc, BookmarkState>(...)

React Router

export async function loader({ context }) { return await context.kv.get("bookmarks"); } // In component: const bookmarks = useLoaderData(); // Data is already here!

Server State

Loaders & Actions — DB data, API calls

Client State

useState — UI toggles, form inputs, modals

In Flutter, you trigger a BloC event in initState to fetch data.
In React Router, the loader fetches data before the component renders.

Navigation IS the event. Loader IS the state.
The Other Half of #2

Reading the Data

Flutter BloC

BlocBuilder<BookmarkBloc, BookmarkState>( builder: (context, state) { if (state is BookmarkLoaded) { return ListView( children: state.bookmarks .map((b) => ListTile(title: b.title)) .toList(), ); } return CircularProgressIndicator(); }, )

React Router

// Option 1: Route prop export default function Bookmarks({ loaderData }) { const { bookmarks } = loaderData; ... } // Option 2: Hook (nested components) const { bookmarks } = useLoaderData(); // No loading check — data is already there!
BlocBuilder<State>useLoaderData()
The Critical Shift

Request/Response vs Stream

Flutter / BloC

UI
Event
BloC
Stream
State
rebuild
UI

Client-side. Streams are long-lived.

React Router

Request
Loader (SERVER)
data
Component
render
HTML

Request-scoped. Per-navigation.

⚠️ Critical Difference

Lifecycle: Long-Lived vs Ephemeral

Flutter BloC — Long-Lived

  • BloC persists in memory (if provided globally)
  • Stays alive while widget is in tree
  • You often cache data in the BloC itself
  • Navigate away and back? Data still there.

React Router — Ephemeral

  • Loaders run on every navigation
  • Navigate away and back? Loader runs again.
  • Data does not persist automatically
  • This is usually what you want! Fresh data.
Need persistent state (shopping cart)? Use Context, Zustand, or HTTP caching.
Key Web Concept

The Browser is Part of Your State

Flutter

State lives in memory
App controls all navigation
Restart = fresh state
Deep links are opt-in

Web

URL is the source of truth
Back/Forward buttons exist
Refresh = reload from URL
Every route is a deep link

Loaders Re-run

Back button? Loader runs again.

Bookmarkable

Users share URLs. State must restore.

No "Warm" State

Every page load starts fresh.

React Router syncs URL ↔ Loaders ↔ UI automatically
The Key Difference

Loaders & Actions Run on the Server

// This code runs on the SERVER, not in the browser export async function loader({ context }) { // No separate API to build - loader IS the API return await context.kv.get("bookmarks"); } export async function action({ request, context }) { // Secrets stay on server - never exposed to client await context.kv.put("bookmarks", data); }

No API Layer

Loaders ARE your API

Secrets Stay Safe

DB credentials never reach browser

SSR Built-in

HTML rendered on server, hydrated on client

React Router = Frontend + BFF in one
Mental Model #3

Forms Trigger Actions

BloC — Adding Data

context.read<BookmarkBloc>().add( AddBookmark(title: "...", url: "...") ); on<AddBookmark>((event, emit) async { await repository.add(event.bookmark); emit(BookmarkAdded()); });

React Router — Adding Data

<Form method="post"> <input name="title" /> <input name="url" /> <Button type="submit">Add</Button> </Form> export async function action({ request }) { const formData = await request.formData(); await addBookmark(formData.get("title"), ...); return redirect("/bookmarks"); }

From imperative onPressed: () => bloc.add(Event) to declarative <Form>.
The router handles submission like a BloC handles an added event.

<Form method="post"> = bloc.add(Event())
UI Feedback

Loading States with useNavigation

Flutter BloC

BlocBuilder<BookmarkBloc, BookmarkState>( builder: (context, state) { if (state is BookmarkLoading) { return CircularProgressIndicator(); } return ElevatedButton( onPressed: () => bloc.add(Save()), child: Text('Save'), ); }, )

React Router

function SaveButton() { const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; return ( <Button type="submit" disabled={isSubmitting}> {isSubmitting ? "Saving..." : "Save"} </Button> ); }
navigation.state: "idle" | "submitting" | "loading"
Mental Model #4

<Outlet /> is Your Child Navigator

Flutter GoRouter

ShellRoute( builder: (context, state, child) => Scaffold( body: child, // Child route here ), routes: [ GoRoute(path: 'bookmarks', ...), GoRoute(path: 'bookmarks/:id', ...), ], )

React Router

// routes.ts route("bookmarks", "./bookmarks.tsx", [ route(":id", "./bookmark-detail.tsx"), ]) // bookmarks.tsx export default function BookmarksLayout() { return ( <div> <Sidebar /> <Outlet /> {/* Child route here */} </div> ); }
GoRouter's child = React Router's <Outlet />
The Daily Driver

Getting Around

Flutter (Imperative)

// GoRouter context.go('/bookmarks'); context.push('/bookmarks/123'); // Navigator Navigator.push(context, route);

React Router

// Declarative (preferred) <Link to="/bookmarks">View All</Link> <Link to={`/bookmarks/${id}`}>Details</Link> // Imperative (when needed) const navigate = useNavigate(); navigate('/bookmarks');

<Link>

Renders an <a> tag, works with browser

useNavigate()

After form submit, programmatic redirects

Prefer <Link> — it's accessible and works without JavaScript
Today's Project

QuickMarks — A Bookmark Manager

QuickMarks
React Docs [View] [Delete]
Tailwind CSS [View] [Delete]
Cloudflare Workers [View] [Delete]
Add Bookmark
Title...
https://...
Save Bookmark
3 routes 2 actions 1 loader

Real architecture. Tiny scope. Let's build it.

App Architecture

Data Model & Routes

Bookmark Interface

interface Bookmark { id: string; // UUID title: string; // User-provided name url: string; // The bookmarked URL createdAt: string; // ISO timestamp isFavorite: boolean; // Favorite flag }

Route → Function

/bookmarks loader action
/bookmarks/:id loader

getBookmarks()

Fetch all bookmarks

getBookmark(id)

Fetch single by ID

addBookmark(data)

Create new bookmark

deleteBookmark(id)

Remove bookmark

toggleFavorite(id)

Toggle favorite status

Cheat Sheet

Quick Reference

I want to... Flutter / BloC React Router
Fetch data on load on<LoadEvent> loader function
Submit a form bloc.add(Event()) <Form> -> action
Show loading state BlocBuilder + Loading useNavigation()
Access route params state.pathParameters params.id
Navigate context.go('/path') useNavigate()
Render child route child in ShellRoute <Outlet />
Handle errors try/catch + Error state ErrorBoundary
Error Handling

Route-Level Error Boundaries

Flutter BloC

on<LoadBookmarks>((event, emit) async { try { final data = await repo.getAll(); emit(BookmarkLoaded(data)); } catch (e) { emit(BookmarkError(e.message)); } }); // In widget: if (state is BookmarkError) { return ErrorWidget(state.message); }

React Router

// In the same route file: export function ErrorBoundary() { const error = useRouteError(); return ( <div className="error"> <h2>Something went wrong</h2> <p>{error.message}</p> </div> ); }

Loader Errors

Caught automatically

Action Errors

Caught automatically

Render Errors

Caught automatically

Export ErrorBoundary from any route — errors bubble up to nearest boundary
Your Turn

Build the QuickMarks App

Pre-configured

Styling, routing, API client

You'll Build

Loaders, actions, components

Stretch Goal: Favorites

  • Add "favorite" intent to the action
  • Add a <Form> with star button
  • Sort favorites to the top of the list

Stuck?

Checkout a checkpoint branch

Workshop Time

Let's Build

Same patterns. Different syntax.
You've got this.

npm run dev