DEV Community

Cover image for How To Cross From React To Flutter Fast: A Practical Roadmap For JS/TS Developers
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

How To Cross From React To Flutter Fast: A Practical Roadmap For JS/TS Developers

The brief that arrives after the web app ships: "We need a mobile app."

You already know React. You have shipped with it, the team writes TypeScript, and the code-sharing story looks perfect on paper. So you reach for React Native, because it is the obvious answer.

Then the upgrade lands, and the project you loved stops running on your own machine. I know the exact shape of that weekend. My React Native app, the one I shipped and loved, died in the Expo SDK 54 upgrade. Metro refused to build. The root cause, found after hours of debugging, was a space in my Windows username. A single character in a path.

Here is what that failure taught me, and it reframes the whole decision: the same class of bug breaks Flutter too. The Flutter SDK has a documented limitation with paths containing spaces, and flutter doctor will not catch it. React Native and Flutter share an enemy that has nothing to do with either framework. Most comparison articles miss this entirely, because they compare features instead of failure modes.

This article is the roadmap I actually walked from React to Flutter. It is written for JS/TS developers who need to ship a real mobile app this year, and it is deliberately not a tutorial. Tutorials teach you widgets. This teaches you the model underneath them, and once the model clicks, the widgets stop being mysterious.


Table of Contents


Stop Comparing Features. Pick Your Failure Mode

It is 2026, and every React Native versus Flutter article still opens with a feature table. Renderer performance. Bundle size. Hiring pool. Then a verdict that was probably decided before the article was written.

That is the wrong question, and it is why those articles never change anyone's mind.

The right question is: which failure mode can you live with?

A framework is a bet you make with your future time. Today's feature list matters less than the way the technology will fail you over the next two years, and every framework fails differently. Judge the failure mode, and the feature table becomes irrelevant.

React Native: The Failure Mode Is Churn

The React Native you learned does not exist anymore. The bridge, the architecture you were taught to reason about, was removed entirely in React Native 0.85. The New Architecture became the default in 0.82, Legacy Architecture was frozen in June 2025, and the bridge that made React Native "React for mobile" is gone.

The migration numbers tell the story:

  • Expo SDK 54 was the last SDK where you could disable the New Architecture at all.
  • Roughly 83 percent of SDK 54 EAS Build projects were already running it by January 2026.
  • SDK 55 runs entirely on it. There is no choice anymore.
  • Legacy Architecture, frozen June 2025, was removed along with the bridge in 0.85.

Every major release is now a migration. The framework you remember has already migrated itself twice in three years, and it may do it again. If you choose React Native in 2026, you are choosing to learn the post-migration framework along with everyone else, and you are betting that the churn stops.

The churn is the failure mode. It does not show up in a feature table. It shows up on the weekend your project stops building.

Flutter: The Failure Mode Is A Learning Curve

Flutter's failure mode is different. It is a learning curve. Dart is a second language. Widgets are not DOM nodes. There is no CSS. The engine ships with your app, so the bundle is bigger at the start.

Those are real costs. A developer who refuses to learn a new language loses to Flutter before writing a line of it.

But the platform underneath is remarkably stable. Flutter's rendering model has not been ripped out and replaced. The engine is first-party, the tooling is first-party, and the framework can afford to be boring because it owns its entire stack. In Flutter 3.44, the release notes are the opposite of a migration: Impeller is the default renderer on Android and Skia is gone for Android 10 and up, Material and Cupertino are frozen and heading for standalone packages, and Swift Package Manager is the default for iOS and macOS.

Stability is a feature that comparison tables cannot measure. It is the difference between a weekend spent rebuilding and a weekend spent shipping.

Lynx: The Failure Mode Is Immaturity

Lynx deserves an honest look, because a TikTok-backed, React-inspired framework is exactly the kind of bet that pays off early. Here is the 2026 reality:

  • It is open source and fast, with a dual-thread architecture and the Rspack build system.
  • It is currently embedded-only. You cannot scaffold a standalone app yet.
  • The community is small: roughly 15,000 GitHub stars against about 120,000 for React Native and 170,000 for Flutter.
  • The 2026 roadmap promises monthly releases, WebAssembly, and a scaffolding tool.

The failure mode is immaturity, and for a solo founder or a small team with a product to ship this year, immaturity is disqualifying. Lynx is worth checking again when the scaffolding tool ships. It is a chapter, not a recommendation.

The Decision, Compressed

Your situation The honest read
You already know React and will not learn Dart React Native, and budget for the migration churn
You can invest a few weeks in a new mental model Flutter, and the learning curve is the price of stability
You want the bleeding edge and have time to burn Lynx, and expect to rebuild everything yourself
You need a product in the store this year Flutter, and do not look back

The question that decides your next year drops the renderer talk entirely. It is this: can you keep this project running on your own machine, through the upgrades, on the networks you actually work on? Judge the failure mode you can live with, and the rest of the roadmap falls into place.

What The Comparison Articles Get Wrong

"React Native is still React, right? Same components, same language, same TypeScript."

Before moving to the model, three recurring claims deserve a direct answer, because they will be in the comments of any Flutter-versus-React-Native post, and they are each only half true.

"Flutter apps are huge." Directionally true at the minimum: a minimal Flutter Android APK runs 16 to 20 MB while a minimal React Native build runs 8 to 12 MB, because Flutter ships its own engine and Dart runtime. The gap closes fast as the app grows. Benchmarks of the same feature set have shown Flutter shipping smaller than React Native at comparable complexity, because React Native still carries the JS engine plus native views while Flutter's AOT output is compact machine code. Bundle size matters, and it is not the deciding factor for almost any real product. Ship the app that runs reliably.

"React Native means you keep using TypeScript." True, and it is the strongest argument for staying. The counter is that the daily experience is no longer the TypeScript you know. The New Architecture introduced a new threading model, new native interop, and new debugging surfaces. You keep the language and inherit the migration. Flutter costs you a few days of Dart familiarization and gives you a stable platform underneath. Both trades are legitimate; they trade different things.

"The JS/TS ecosystem gives you better libraries." The npm package world is enormous, and the mobile-flavored slice of it is thinner than it looks. The packages that survive on mobile tend to be the ones that wrap native code, and native wrapping is where React Native historically breaks. Flutter's smaller package world has fewer abandoned packages, because the ones that matter are maintained by the Flutter team or by a small number of serious maintainers. The safety comes from curation, and curation is a feature when you are trying to ship.


The Mental Map: React Native vs Flutter

The fastest way to learn Flutter is to understand the one architectural fact underneath everything: React Native is a JavaScript library that borrows the platform's native views. Flutter owns the entire rendering pipeline.

That single fact explains every "why" in this article.

React Native renders through the platform. Your React component tree maps to native views through a bridge, and the bridge has to translate between JavaScript and native on every interaction. That is why the bridge existed, why it had a performance cost, and why the New Architecture rewrote it.

Flutter renders with its own engine. Your widget tree is compiled and drawn by Flutter itself, using Impeller on modern Android and iOS. There is no DOM, no browser, no native view to borrow, because Flutter does not borrow anything. The result is a framework that behaves identically on every platform, because every platform runs the same engine.

Follow that thread and the foreign parts explain themselves. CSS cannot transfer, because it styles a DOM and Flutter does not have one. Widgets are immutable, because a widget is a configuration object, not a live node. Hot reload can preserve state, because the element tree survives the reload while Metro has to rebuild the whole app. The renderer decision is upstream of every "why" you will ask in week one.

Here is the mental map that made it click for me. Keep it nearby, because every time something in Flutter feels foreign, it is one of these rows.

JS/TS / React world Flutter / Dart world
Component function + hooks Widget classes, StatefulWidget + State
JSX returns a virtual tree build() returns a widget tree
DOM nodes, mutate what changed Immutable widgets, rebuild on state change
useState, useEffect setState, widget lifecycle methods
Context, Redux, Zustand InheritedWidget, Provider, Riverpod, Bloc
CSS: flexbox, grid, media queries Layout widgets: Row, Column, Stack, Expanded
npm / pnpm / yarn pub, pubspec.yaml
Metro, Vite, webpack Built-in flutter run, hot reload
Jest + React Testing Library flutter_test: unit, widget, integration
TypeScript types (optional) Sound null safety, enforced at compile time
Browser + server share one language One language, compiled JIT in dev and AOT in release

The rows are not "better" or "worse". They are a mapping. You already know the left column. The right column is a translation, and translations are faster to learn than new languages, because the concepts carry over even when the syntax does not.

The mental map: JS/TS React concepts on the left, Flutter and Dart equivalents on the right, with the unlearning rows (CSS, the single-language assumption) flagged in amber.

The amber rows are the ones that will fight you. Everything else transfers, and most of the first week is spent accepting that the amber rows work on a different model, not that they are missing.

The architectural overview is the official deep dive on the three trees (widget, element, render). Read it once early. It is the closest thing to a map of the territory, and it will save you weeks of confusion about why the framework behaves the way it does.


You Can Read Dart Already

The part nobody warns you about before the first tutorial: the language is the least foreign piece of this crossing. Proof, in one minute:

int discountFor(int amount) {
  if (amount >= 500) {
    return 50;
  }
  if (amount >= 100) {
    return 10;
  }
  return 0;
}

void main() {
  print(discountFor(150)); // 10
}
Enter fullscreen mode Exit fullscreen mode

You just read that, because it is JavaScript with explicit types and a few different keywords. int and void are written out, main() is the entry point, and print is console.log. That is most of the first-week Dart syntax lesson, finished.

One detail is worth stopping on now, because it trips up JS developers more than any other word. Dart's final is not JavaScript's const:

final numbers = [1, 2, 3]; // assigned once
numbers.add(4);            // this is allowed

const pi = 3.14159;        // compile-time constant, truly immutable
Enter fullscreen mode Exit fullscreen mode

final means "this variable points to one thing". The thing itself can still change. const means "this value is baked at compile time", and that is the closest match to what you think const does. Get that one word straight and the language stops fighting you.

Read the syntax fast, then leave it. The crossing does not happen in syntax-land. It happens in the mental model, the state scopes, and the toolchain, and those are the sections this roadmap spends its time on.


Unlearn These Web Habits First

The curriculum is the stuff you must unlearn, and it is shorter than the new stuff you must learn. Each item below names the web habit, explains why it cannot transfer, and shows the replacement.

1. There Is No CSS

In React, you write a component and style it with classes, flexbox, grid, and media queries. In Flutter, layout and styling are code.

  • Spacing becomes EdgeInsets.all(16) where you wrote margin: 16px.
  • Centering becomes a Center widget, or Row with mainAxisAlignment, or Stack with Positioned, where you wrote display: flex; align-items: center.
  • A whole layout is a composition of widgets, not a stylesheet.

The first week feels like building with your hands tied, because your styling muscle memory does not transfer. The model is simply different, and it is consistent once you accept it. The payoff is that every layout decision is explicit and searchable in code. There is no cascade, no specificity war, no "which stylesheet wins". The layout you see is the layout you wrote.

2. Widgets Are Immutable Declarations

A React component returns JSX that React diffs against the previous tree and patches the DOM. A Flutter widget is a configuration object. It is cheap, it is immutable, and it is rebuilt constantly. The state lives in the State object, not in the widget.

// React: state lives in the component
function Greeting({ name }: { name: string }) {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Hello, {name}</p>
      <button onClick={() => setCount(count + 1)}>
        Pressed {count} times
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// Flutter: state lives in the State object, the widget is a declaration
class Greeting extends StatefulWidget {
  const Greeting({super.key, required this.name});

  final String name;

  @override
  State<Greeting> createState() => _GreetingState();
}

class _GreetingState extends State<Greeting> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Hello, ${widget.name}'),
        ElevatedButton(
          onPressed: () => setState(() => _count++),
          child: Text('Pressed $_count times'),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The shape is familiar. Greeting is the function, State is the hooks closure, build() is the render, setState is the trigger. But the model underneath is different: you describe what the screen should look like for a given state, and Flutter figures out what changed. You do not mutate. You declare.

3. BuildContext Replaces A Few Things At Once

React gives you props, context, and hooks. Flutter gives you a single currency called BuildContext, and it takes a while to see that it is props, context, and the component tree at once.

You will see context everywhere, and your instinct to pass state down through constructors will fight with the idiomatic context.watch and InheritedWidget patterns. Let the instinct go. BuildContext is the tree, and the tree is the dependency graph. When something in Flutter seems to "just know" a value, the answer is usually that it read it from the tree through a context.

4. State Has Explicit Scopes

In React, useState is local, Context is inherited, and Redux is global, and the boundaries are soft. In Flutter, the scopes are explicit and you choose them on purpose:

  • setState for widget-local state.
  • InheritedWidget or Provider for inherited state.
  • Controllers (with ChangeNotifier) or Bloc/Riverpod for feature state.

The clarity is a feature. You always know where state lives, which means you always know where a bug can live. The state management options guide is worth reading once to see the official framing: keep it simple, and reach for a heavier solution only when the simple one gets long.

5. The Widget Lifecycle Maps To Hooks

React developers learn the hooks mental model early: useEffect for side effects, useRef for mutable values, cleanup functions for teardown. Flutter's widget lifecycle is the same set of concerns with different names, and mapping them saves you the confusion of the first week:

React hook Flutter lifecycle What it is for
useState State fields + setState Mutable state scoped to the widget
useEffect(() => {...}, []) initState Setup that runs once when the widget mounts
useEffect(() => {...}, [dep]) didUpdateWidget Reacting to a change in the widget's configuration
useEffect cleanup dispose Teardown: listeners, timers, controllers, streams
useContext BuildContext + context.watch Reading inherited values from the tree

The pattern that trips up React developers is dispose. In React you return a cleanup function from useEffect; in Flutter you override dispose on the State object and remove listeners, cancel timers, and close controllers there. Forget it and you get the mobile equivalent of a memory leak: a controller that keeps notifying a dead widget, a timer that fires forever, a stream subscription that was never cancelled.

The mental model is the same as hooks. The placement is just explicit instead of inferred, and explicit is easier to debug.

6. Async Is Futures And Streams

You already know async/await. Dart has it, and it behaves the way you expect. What web developers miss is that Dart's streams are first-class in a way JavaScript never made them. A stream is an observable sequence, and Flutter's UI is full of them: taps, sockets, file reads, and reactive state.

The mental model: a Future is a promise, and a Stream is an observable. React never gave you a native observable; you reached for RxJS or Zustand or a store. Flutter ships streams in the core, and the StreamBuilder widget lets you render a UI that reacts to a stream without any library.

7. The Package Manager Is Smaller, On Purpose

npm replaced by pub. The Dart package world is smaller than npm and that is a feature, not a deficit. The packages you need for a real app, HTTP, storage, state, and routing, are stable and well maintained. You will not find a package for everything, and the missing ones keep you from assembling an app out of abandoned half-frameworks. The pub.dev landing page shows the curated packages, and the quality bar is higher because the package world never went through npm's publishing free-for-all.


Dart: Learn Enough, Then Move On

If you write TypeScript, Dart is the least scary part of this crossing. It is an object-oriented language with async/await, final instead of const for most things, generics, and a standard library that behaves.

And you can start today, in the browser, with zero installation.

Start In DartPad Before You Install Anything

DartPad is the official in-browser editor for Dart. It runs the real Dart compiler in your browser, it has a Flutter mode, and it removes the single biggest fear in this whole crossing: installing an SDK just to try a language.

The fastest first session looks like this:

  1. Open DartPad.
  2. Switch to Flutter mode.
  3. Write a Row with two Text widgets and an ElevatedButton.
  4. Call setState and watch the counter change.

You will have written and run real Flutter code in under ten minutes, before touching your terminal. That is the answer to "where do I start": start in the browser, and install the SDK only when you are ready to build a real app. The official Dart language tour is right there next to it.

Why Dart Exists: The JIT/AOT Duality

Dart's real trick is that it compiles twice, and the framework uses both modes.

  • In development, Dart runs on a just-in-time compiler. Hot reload works because the JIT can swap code into a running app.
  • In release, Dart is compiled ahead-of-time to native machine code. Your app starts from native code, not from a JavaScript runtime booting first.

This is why Flutter has the developer experience of an interpreted language and the release performance of a compiled one. React Native spends its performance story on the bridge; Flutter spends it on the language. The build modes documentation is the official explanation of the three modes and what each one runs.

Null Safety Is Enforced, Not Configured

TypeScript's strictNullChecks is a linter you configure. Dart's null safety is part of the language, and every variable is nullable or non-nullable by declaration.

// TypeScript: strictNullChecks is optional and per-project
const user = getUser(); // user: User | null
console.log(user.name); // error only if the flag is on
Enter fullscreen mode Exit fullscreen mode
// Dart: null safety is the language
final User user = getUser(); // compile error if getUser() can return null
User? maybeUser = null;       // explicit nullable, you must handle it
Enter fullscreen mode Exit fullscreen mode

You cannot ship a null crash that the compiler could have caught. For a developer coming from a language where null is a runtime surprise, this feels like a cheat code, and it is the single biggest quality win of the crossing.

Records, Patterns, And Sealed Classes Are Upgrades

Dart 3 added records (anonymous, immutable data bundles), pattern matching, and sealed classes. If you use discriminated unions in TypeScript, this will feel like home:

// TypeScript: discriminated union
type PaymentState =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "success"; amount: number }
  | { kind: "error"; message: string };
Enter fullscreen mode Exit fullscreen mode
// Dart: sealed class + switch expression, exhaustiveness checked
sealed class PaymentState {}

class PaymentIdle extends PaymentState {}

class PaymentLoading extends PaymentState {}

class PaymentSuccess extends PaymentState {
  PaymentSuccess(this.amount);
  final double amount;
}

class PaymentError extends PaymentState {
  PaymentError(this.message);
  final String message;
}

String describe(PaymentState state) => switch (state) {
      PaymentIdle() => 'Waiting',
      PaymentLoading() => 'Working',
      PaymentSuccess(:final amount) => 'Paid $amount',
      PaymentError(:final message) => 'Failed: $message',
    };
Enter fullscreen mode Exit fullscreen mode

The switch expression is exhaustive. Add a new subclass of PaymentState and forget to handle it, and the code does not compile. TypeScript gives you a lint warning for that. Dart gives you a compile error. The difference is the default that protects you.

Dart 3.12 previewed primary constructors, and Dart 3.13 makes them stable, so a class User(this.name) replaces the boilerplate field-and-constructor pair. The language keeps converging on the things that make modern JS pleasant, then makes them the only way to write.

The Language Is Not The Obstacle

For a TypeScript developer, Dart is a few days of familiarization, not a month of study. The syntax is close enough that you will read it before you write it. The real learning curve in this crossing is the widget model, the state scopes, and the toolchain. Those are the sections this roadmap spends its time on, because that is where the time actually goes.


Fix The Toolchain First

The comparison articles go quiet here, because the toolchain is where the crossing lives or dies. Three facts will save you the weekends that the feature tables never mention.

Fact 1: Install The Flutter SDK Into A Clean Path

The number one Windows failure, documented in the Flutter issue tracker, is the path with a space. If your user profile is C:\Users\Firstname Lastname, the default install location breaks builds, and flutter doctor will not tell you, because the check does not test for it.

Warning The failure is silent. flutter doctor reports green, then the build dies with "is not recognized as an internal or external command". Check the path first when a Windows build fails on a machine you did not set up.

C:\Users\Firstname Lastname> flutter --version
'C:\Users\Firstname' is not recognized as an internal or external command,
operable program or batch file.
Enter fullscreen mode Exit fullscreen mode

That exact output is documented in the issue tracker, and the report explicitly notes that flutter doctor does not detect the problem. The path is split at the space, the second half is dropped, and Windows tries to run the first half as a command.

The fix is boring and permanent: install the SDK somewhere plain like C:\src\flutter. If you have been fighting Metro on the same machine, the same rule applies to your entire development root. Give yourself one clean path and stop paying the toolchain tax forever. The official Windows install guide is short and worth following exactly once.

Fact 2: pub Replaces npm, And It Is Calmer

Flutter packages come from pub, and pubspec.yaml replaces package.json:

name: mobile_app
description: The mobile client for a form builder.
publish_to: "none"
version: 1.0.0+1

environment:
  sdk: ^3.12.2

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.10.0
  flutter_secure_storage: ^10.3.1
  url_launcher: ^6.3.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0
Enter fullscreen mode Exit fullscreen mode

The commands map directly: flutter pub get for install, flutter pub add dio for adding a package, flutter pub outdated to check updates. The lockfile works the same way. The calm part is that you will spend far less time reconciling a dependency tree, because the tree is smaller and the package world is curated.

Fact 3: Build Modes Explain The Whole Performance Story

Mode What runs What it is for
Debug Dart JIT, hot reload, assertions, no tree shaking Development
Profile JIT with optimizations Performance analysis
Release Ahead-of-time native machine code, tree shaken Shipping to stores

The release build compiles your Dart to native machine code before the app starts. No JavaScript engine, no bridge, no interpreter sits between your code and the device. That is the "no bridge" story the Flutter community repeats, and it is real: your app starts from native code, not from a JS runtime booting first. Tree shaking means dead code is removed at build time, which is why the release bundle is smaller than the debug bundle despite shipping the engine.

Flutter build modes: debug runs the JIT with hot reload and assertions, profile runs an optimized JIT for performance analysis, and release compiles ahead-of-time to native machine code with tree shaking.

The mode you measure changes what you learn. Debug frame times are about the JIT, not about your app, which is why the docs tell you to profile in profile mode and judge truth in release.

Hot reload deserves its own sentence, because it is the closest thing to a superpower in Flutter. Change a widget, save, and the running app updates in about a second while preserving state. No Metro rebuild, no reloading the whole app, no losing your place in a flow. For a web developer used to the browser refresh cycle, the first week of hot reload feels like cheating. It is the debug JIT doing its job.


Reuse Your Web Architecture

Syntax and unlearning get you to hello world. The crossing happens when you build one real app and grow it through the roadmap. The best artifact for a web developer crossing to Flutter is an authenticated app that talks to a real backend, because it exercises everything: HTTP, state, storage, navigation, and tests.

Here is the shape that works, with the details you would recognize in any production Flutter project.

Feature-First Folders, The Same Habit You Already Have

If you organize React apps by feature, you already know this structure. If you do not, start now, because Flutter rewards it:

lib/
  main.dart              # entry point, boots the app config
  app/
    bootstrap.dart       # wires dependencies before runApp
  core/
    api/
      api_client.dart
    auth/
      auth_controller.dart
    design_system/       # colors, spacing, radius, theme tokens
  features/
    auth/
      auth_repository.dart
      login_screen.dart
    builder/
    forms/
    responses/
Enter fullscreen mode Exit fullscreen mode

The pattern is the same as a React feature folder: each feature owns its repository, its controllers, and its screens, and the core layer holds what everything shares. bootstrap.dart is the one file that surprises web developers, and it is worth understanding. It is where dependencies get wired before the app starts, the Flutter equivalent of composing your providers and stores at the root instead of letting a service container magic them together.

The API Client: The Best Reusable Artifact

The first time I crossed this gap, the moment that made the whole model click was the API client. In the JS world you reach for axios or fetch with an interceptor, and you already know the shape: send the token, retry on a 401, keep timeouts honest. In Flutter, dio is the same idea, and it is the best first artifact to bring with you, because it is the same code in a new syntax:

Every network call needs a timeout. A mobile client runs on networks that change mid-request, and a request that hangs forever is worse than one that fails fast.

class ApiClient {
  ApiClient({
    required this.baseUrl,
    required this.sessionStore,
    required this.refresh,
  }) {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 15),
      receiveTimeout: const Duration(seconds: 20),
      sendTimeout: const Duration(seconds: 20),
      headers: {
        'X-Client': 'mobile',
        'X-Platform': 'android',
      },
    ));

    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) async {
          final token = await sessionStore.readToken();
          if (token != null) {
            options.headers['Authorization'] = 'Bearer $token';
          }
          handler.next(options);
        },
        onError: (error, handler) async {
          final isAuthFailure = error.response?.statusCode == 401;
          if (!isAuthFailure) {
            handler.next(error);
            return;
          }
          final ok = await refresh();
          if (!ok) {
            handler.next(error);
            return;
          }
          final retry = await _dio.fetch(error.requestOptions);
          handler.resolve(retry);
        },
      ),
    );
  }

  late final Dio _dio;
  final SessionStore sessionStore;
  final Future<bool> Function() refresh;
}
Enter fullscreen mode Exit fullscreen mode

Four details are worth internalizing, because they map to production concerns you already know from the web:

  1. Explicit timeouts. Fifteen seconds to connect, twenty to send, twenty to receive. They are set once, at construction, so no screen can forget them.
  2. The 401 refresh-and-retry. When the access token expires, the client refreshes it once and retries the original request. This is the interceptor pattern you already use in axios, and it keeps you from writing error handling in every screen.
  3. Platform headers on every request. A server wants to know which client is talking to it, and the headers are set once, not pasted into every call.
  4. Constructor injection. The client receives its dependencies (base URL, store, refresh function) explicitly. There is no hidden container. This is the same lesson every senior backend engineer learns: explicit composition beats framework magic. It carries over to Flutter exactly.

State With ChangeNotifier Controllers

The state pattern that keeps an app simple without adding a framework is a plain ChangeNotifier controller. It is the Flutter equivalent of a custom hook plus a small store, and it is enough until the app grows beyond a few screens.

enum AuthStatus { bootstrapping, signedOut, signedIn }

class AuthController extends ChangeNotifier {
  AuthController({required this.repository});

  final AuthRepository repository;

  AuthStatus _status = AuthStatus.bootstrapping;
  String? _errorMessage;

  AuthStatus get status => _status;
  String? get errorMessage => _errorMessage;
  bool get isSignedIn => _status == AuthStatus.signedIn;

  Future<void> restore() async {
    _status = AuthStatus.bootstrapping;
    notifyListeners();
    // read the session from secure storage, then transition
  }

  Future<void> signIn(String email, String password) async {
    try {
      final session = await repository.signIn(email, password);
      _status = AuthStatus.signedIn;
    } catch (error) {
      _errorMessage = error.toString();
    } finally {
      notifyListeners();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The pattern is small and it scales: the controller holds state, exposes read-only getters, mutates state through methods, and notifies listeners. Widgets subscribe with ListenableBuilder or AnimatedBuilder, and the wire-up happens in the bootstrap, constructor injected just like the API client.

This is deliberately boring. Riverpod and Bloc exist and are excellent, but a solo builder or a small team should start with the boring pattern and reach for the heavier solutions only when the controller file gets long. The official state management documentation says the same thing in gentler language.

The same instinct, boring over clever, carries into the design system, and this is where Flutter starts to feel less like a web port and more like its own thing.

Design Tokens: Tailwind's Constraints, TypeScript's Safety

Instead of a CSS file or a Tailwind config, you define tokens as Dart classes:

class AppSpacing {
  const AppSpacing._();

  static const double xs = 4;
  static const double sm = 8;
  static const double md = 12;
  static const double lg = 16;
  static const double xl = 20;
}

class AppColors {
  const AppColors._();

  static const Color accent = Color(0xFF6366F1);
  static const Color background = Color(0xFFF8FAFC);
  static const Color textPrimary = Color(0xFF0F172A);
}
Enter fullscreen mode Exit fullscreen mode

The tokens are typed, autocompleted, and impossible to typo. No stringly-typed class name can silently stop matching. It is Tailwind's constraints with TypeScript's safety, and it makes a consistent design system the default rather than the achievement.

Navigation

Navigation is the other big shift. In React you use a router library; in Flutter the navigation is built in, and the mental model is a stack of routes. The two patterns you will see are named routes and go_router, which gives you URL-style paths and deep linking. For a web developer, go_router feels closest to home because you write paths and parameters the way you write routes in Express or Next.js.

Deep links deserve a note now, because they are the mobile web habit that does not carry over by default. A URL that opens your app at a specific screen is something you configure, not something you get. Add it to the roadmap early, because retrofitting deep links is harder than building them in.


Keep Your Testing Discipline

If you came from Jest, do not skip this section. Testing is where your habits transfer cleanest, and it is also where web developers talk themselves out of good practice for the wrong reason. Flutter has three test categories, and the official docs are refreshingly honest about the trade-off:

Category Confidence Maintenance cost Speed
Unit test Low Low Fast
Widget test Medium Medium Medium
Integration test Highest Highest Slow

The docs say a well-tested app has many unit and widget tests plus a few integration tests. Notice what that means: a project with one widget test sits at the correct starting shape, and calling it lazy misreads the trade-off. You add tests where the behavior is important, you keep the suite fast, and you let the few integration tests carry the confidence that needs a real device.

A widget test is a component test under a different name. If you wrote tests with React Testing Library, the mapping is almost one to one:

React Testing Library Flutter test
render(<App />) tester.pumpWidget(const App())
screen.getByText('Hello') find.text('Hello')
fireEvent.click(button) tester.tap(button)
await waitFor(...) await tester.pumpAndSettle()
testWidgets('shows the greeting and counts presses', (tester) async {
  await tester.pumpWidget(const App(name: 'Ada'));

  expect(find.text('Hello, Ada'), findsOneWidget);

  await tester.tap(find.text('Pressed 0 times'));
  await tester.pump();

  expect(find.text('Pressed 1 times'), findsOneWidget);
});
Enter fullscreen mode Exit fullscreen mode

The web developer reflex is to over-test the rendering layer and under-test the logic. Flutter makes the opposite easy to get right, because the logic lives in plain Dart classes that are trivial to unit test without a widget tree. Test the repository and the controller as plain classes, keep a small number of widget tests for the screens that matter, and write one integration test per critical user journey.

Flutter also has golden tests, which snapshot a widget's rendered output and fail when the snapshot changes. There is no exact equivalent in the React testing world; it is the closest thing to visual regression testing built into the framework. Use them sparingly for stable, important screens, and expect to update snapshots whenever you change the design system.


Avoid These First-Week Traps

The traps below are not theory. They are the mistakes that cost real weekends, and they are all avoidable if you know they exist before you hit them.

The One Widget Test Trap Is Real, In Reverse

Most first-week Flutter developers write zero tests, because the widget tree feels too visual to test. The fix is the mapping table above: test the controller as a plain class first, and the widget test stops feeling mysterious. A project that starts with a controller test and one widget test is already ahead of most tutorials.

State Lost On Navigation

In React, navigating away and back usually keeps component state if the component stays mounted. In Flutter, pushing a route on top does keep the screen below it alive, but popping back does not restore state that lived in the popped route. The lesson: put state that must survive navigation in a controller or store, and keep ephemeral UI state in setState. Decide which is which on purpose, and you will never lose a form mid-way.

Hot Reload Works, Then Breaks

Hot reload is a superpower until it is not. It preserves state only when the change is to the widget tree. Changing a top-level function signature, a main() body, or a global initializer requires a full hot restart, and beginners assume they broke the app. The tooling tells you which you need; learn to read the message instead of restarting blindly. A full hot restart still beats a full rebuild, so the habit to build is "save, glance at the output, act on what it says".

The Debug Build Is Slow And That Is Correct

The first time you profile a debug build, the frame times look alarming. That is the JIT doing its job, and it is why the docs tell you to measure on release builds. Learn this early: debug for iteration, profile for analysis, release for truth. Measuring the wrong mode produces conclusions that are wrong in both directions.

Screens Look Broken On Real Devices

The emulator and the device disagree about fonts, keyboard, and notch space, and the emulator is always more forgiving. Build on a real device from week one, even if it is an old one. The overflow stripes, the keyboard that covers a field, and the first-frame jank are all things that only a real device shows you.

Forgetting dispose

The lifecycle table above is the cure. Every AnimationController, Timer, StreamSubscription, and controller you create in a State should be released in dispose. A leaked listener in a mobile app is a battery drain and a crash waiting for a warm day. React lets you forget cleanup; Flutter makes the bill visible.


Learn The Production Patterns

When the app is real enough to deploy, run this list. Each item maps to a web habit you already have, and each one is easier to do early than to retrofit.

  • Build flavors for staging and production. The mobile equivalent of environment variables, but first-class: different API endpoints, different app names, different icons per environment.
  • Secure storage for tokens. Never plaintext, never in shared preferences. Use a package like flutter_secure_storage, which wraps the platform keychain and encrypted storage.
  • Crash reporting with real stack traces. Release builds strip symbols by default; wire a crash reporter before your first beta, or every crash will be a mystery.
  • CI that runs flutter analyze and flutter test on every push. The same gate you put around a web PR, and it catches the same class of mistakes before they reach a device.
  • Release builds checked. AOT, tree shaken, no debug banner. Run the release build locally on a real device before you claim it is done.
  • Deep links handled. Configure them early, because they are the mobile equivalent of URLs and they do not come free.
  • Analytics that records events, not screens only. Page-view thinking is the web habit; mobile products live on event funnels.

A Realistic Timeline (Not A 12-Week Fantasy)

About time, the straight answer: you can have a working feature in a weekend, a store-ready app in about eight to twelve weeks of focused work, and real fluency in a quarter. The range exists because the variable is not the language. The variable is how many focused hours you actually have, and how much of your web architecture you can carry over.

The plan below avoids fixed weeks on purpose. It is a sequence of phases, and you should spend more time in the phases that fight back.

Phase 1: The First Weekend

Goal: prove the crossing on your machine.

  1. Open DartPad and write your first widget in the browser.
  2. Install the Flutter SDK into C:\src\flutter.
  3. Build the counter example, then rebuild it as a login screen.
  4. Copy the ApiClient pattern, point it at a backend you own, and wire login plus the 401 refresh.

By Sunday night you have a screen that talks to a real server, on your machine, with the toolchain proven. This is the phase that kills the fear, and it is why the quick track and the full roadmap share it.

Phase 2: Weeks 2-4, One Feature End To End

Goal: rebuild one feature from an app you already shipped on the web.

  1. Pick the smallest feature that touches the backend, storage, and navigation.
  2. Build the repository and the ChangeNotifier controller.
  3. Wire the feature screen with loading, empty, and error states.
  4. Write the unit tests for the controller and one widget test for the screen.

This is the phase where the mental model either clicks or fights you. If it fights, reread the unlearning list. Every hour spent here is paid back in every later phase.

Phase 3: Weeks 5-8, Design System And Shell

Goal: make the app look like a product instead of a tutorial.

  1. Define AppColors, AppSpacing, AppRadius, and AppTheme as typed tokens.
  2. Build the app shell: bottom navigation, a home screen, a detail screen.
  3. Add deep links with go_router.
  4. Run flutter analyze and fix every warning.

This is the phase where the design system pays for itself. Because the tokens are typed, the design cannot drift into inconsistency the way a CSS codebase drifts.

Phase 4: Weeks 9-12, Release

Goal: get the app into a store.

  1. Add flavors for staging and production.
  2. Wire crash reporting and analytics.
  3. Set up CI with flutter analyze and flutter test.
  4. Build a release APK, install it on a real device, and live in it for a day.
  5. Fix the things that only show up on a phone in your hand: text overflow, keyboard overlap, slow first frame.

The Quick Track: A Long Weekend

Sometimes you do not have twelve weeks. You have a long weekend and a brief that will not wait. Here is the compressed version for a JS/TS developer who already understands HTTP, state, and component trees. Call it "be productive in Flutter fast and clean up later", and set "learn Flutter properly" aside for when you have room to breathe.

  • Day 1: DartPad, then the counter example, then a login screen. Feel the widget tree under your fingers.
  • Day 2: Copy the ApiClient pattern and wire login plus the 401 refresh against a real backend.
  • Day 3: Rebuild one feature end to end with a ChangeNotifier controller.
  • Day 4: Write the widget test, run flutter analyze, and build a release APK. Install it on a real device.
  • Day 5 (optional): Read 200 lines of an open-source Flutter app and refactor your feature to match the idioms you saw.

After five days you have a working feature, a real API client, a passing test, and a release build. You do not have deep fluency in the widget lifecycle, mastery of the render pipeline, or years of "what is the right way" intuition. Those come later. Five days gets you in the door. Twelve weeks gets you confident.


What The Crossing Really Is

Learning Flutter fast does not mean skipping fundamentals. The point is to put your existing web experience to work in the new model.

You already understand the hard parts: components, state, HTTP, async work, error handling, testing, deployment. You know how business rules become messy, how production bugs hide in small assumptions, and why the toolchain matters more than the syntax. The crossing translates that knowledge rather than rewriting it.

Flutter pushes you toward explicit state. Layout becomes a thing you think about in code. Null becomes a compile-time concern instead of a runtime surprise. The boring, consistent design system gets rewarded over clever abstractions. None of that is a defect. It is a different contract, and it is the reason the framework survives the churn that keeps rewriting its competitors.

The framework that respects the difference between a web page and a real mobile app wins, and that is the part the comparison articles never teach you. The bundle size argument, the renderer argument, the hiring argument, they all miss the one question that decides your next year: can you keep this project running on your own machine, through the upgrades, on the networks you actually work on?

That is the question I should have asked before the SDK 54 weekend. Now I ask it first. React Native has changed more in three years than it did in the decade before. Flutter is not the Flutter of 2023 either, but its changes are upgrades, not migrations. And if you read this roadmap the way it is written, one artifact grown through the phases, the language stops being the obstacle.

You are not starting from zero. A mental map is already in your hands, and the map is more than half the journey.

So stop comparing features. Pick the failure mode you can live with, then ship. That is the whole roadmap, and it is shorter than the feature tables make it look.

If you have shipped with React Native this year and the migration has been smooth, I want the counter-example on record. Tell me where the churn did not hurt and what you did to avoid it. If it bit you the way it bit me, tell me that too. The failure-mode table only gets more honest when it includes the stories the comment section refuses to leave out.


Sources And Further Reading

Primary sources used in this article, all current as of August 2026:

A note on honesty: the React Native bundle-size story is more nuanced than any single number. Minimal React Native builds start lighter than minimal Flutter builds, because Flutter ships its engine, and benchmarks of the same feature set sometimes show Flutter winning. Neither number should decide your framework. Judge the failure mode instead.

Top comments (0)