Cross-Platform Mobile App Development Tutorial (React Native and Flutter Side by Side) - Part 1

This is a comprehensive tutorial that teaches both React Native and Flutter side by side. We will build the same app (a Notes app) in both frameworks, so you can compare how each one solves the same problem. The tutorial is split in three parts:

  • Part 1 (this file): fundamentals and a local Notes app
  • Part 2: connecting the app to a database (Appwrite)
  • Part 3: authentication, per-user data and publishing

Versions used in this tutorial (checked in October 2026). Newer minor versions should work, but if something breaks, come back to this table first.

Tool Version
Node.js 24 LTS
Expo SDK 57 (React Native 0.86, React 19.2, TypeScript)
Expo Router 57
Flutter / Dart 3.47 / 3.13
go_router 18

Visual guide. Use this map to locate the current part before starting the examples.

Four stages: local notes in Part 1, remote data in Part 2, user accounts and release in Part 3. Part 1 is highlighted. Four stages: local notes in Part 1, remote data in Part 2, user accounts and release in Part 3. Part 1 is highlighted.

The highlighted steps are developed in this part.

Table of Contents

  1. Introduction to React Native and Flutter
  2. Development Environment Setup
  3. Project Structure and Fundamentals
  4. UI Components and Styling
  5. Building the Notes App
  6. React Native vs Flutter Comparison

Parts 2 and 3 continue with: 6. Database Integration with Appwrite, 7. Authentication, 8. Filtering User Notes, 9. Empty State, 10. Building & Publishing.


1. Introduction to React Native and Flutter

Expected result. The local Notes app below is the result of Part 1. These are real Android captures of the tutorial code; typography and controls differ between frameworks.

Two initial notes: Welcome and Shopping.

React Native Β· Android Flutter Β· Android
React Native: Two initial notes: Welcome and Shopping. React Native: Two initial notes: Welcome and Shopping. Flutter: Two initial notes: Welcome and Shopping. Flutter: Two initial notes: Welcome and Shopping.

Concepts in this section:

  • What “cross-platform” means: one code base, several targets (Android, iOS, web)
  • Two strategies: native components driven from JavaScript (React Native) vs. own rendering engine (Flutter)
  • How to compare frameworks: language, rendering, ecosystem, learning curve

Visual guide. Follow each row from the source language to the visible interface.

React Native: TypeScript + React β€” Components describe the UI. β†’ React Native runtime β€” Hermes + JSI; Fabric manages native views. β†’ Native views β€” Android and iOS platform UI.; Flutter: Dart + widgets β€” build() describes the widget tree. β†’ Flutter engine β€” Layout and painting of the interface. β†’ Drawn UI β€” Android / iOS Also web and desktop; Native Android: Kotlin + Compose β€” Composable functions describe the UI. β†’ Compose runtime β€” State drives composition, layout and drawing. β†’ Android UI β€” Runs within the Android application. React Native: TypeScript + React β€” Components describe the UI. β†’ React Native runtime β€” Hermes + JSI; Fabric manages native views. β†’ Native views β€” Android and iOS platform UI.; Flutter: Dart + widgets β€” build() describes the widget tree. β†’ Flutter engine β€” Layout and painting of the interface. β†’ Drawn UI β€” Android / iOS Also web and desktop; Native Android: Kotlin + Compose β€” Composable functions describe the UI. β†’ Compose runtime β€” State drives composition, layout and drawing. β†’ Android UI β€” Runs within the Android application.

The three approaches share declarative UI ideas, but use different rendering paths.

React Native Overview

React Native is a JavaScript framework created by Meta that allows developers to build native mobile applications using React. It enables writing code once and deploying to both iOS and Android.

Key Characteristics of React Native:

  • Uses JavaScript/TypeScript as the primary language
  • Leverages the React component model (components, props, state, hooks)
  • Renders real native views (not a WebView)
  • Large ecosystem of third-party libraries, installed with npm
  • Fast Refresh for quick feedback while coding
  • Recommended way to start a project today: Expo (a framework and set of tools built on top of React Native)

Architecture: Modern React Native runs your JavaScript on the Hermes engine. The old asynchronous “bridge” has been replaced by the New Architecture: JSI lets JavaScript call native code directly and synchronously, and Fabric is the new rendering system that creates the native views. As of the version used here, the New Architecture is the only one available, so you do not have to enable anything.

Flutter Overview

Flutter is a UI toolkit created by Google that allows developers to build natively compiled applications for mobile, web and desktop from a single code base.

Key Characteristics of Flutter:

  • Uses Dart as the primary language (compiled ahead of time to native code)
  • Everything is a widget (similar to components in React)
  • Draws every pixel itself instead of using native views
  • Packages are installed with pub (https://pub.dev)
  • Stateful Hot Reload for quick feedback while coding
  • Backed by Google

Architecture: Flutter does not rely on native components. It draws the UI with its own rendering engine, Impeller, which replaced Skia and precompiles shaders to avoid stutter at runtime. This gives Flutter precise control over every pixel and a very consistent look across platforms.

Comparison Between React Native and Flutter

Aspect React Native Flutter
Language JavaScript / TypeScript Dart
UI Real native views (Fabric) Custom rendering engine (Impeller)
JS ↔ native JSI (synchronous, no bridge) Not needed (compiled Dart)
Development experience Fast Refresh Stateful Hot Reload
Learning curve Easy if you know React New language and widget model to learn
Ecosystem npm, very large pub.dev, large and well curated
Backed by Meta (+ Expo, Microsoft, Software Mansion…) Google

2. Development Environment Setup

Expected result. After creating the project, run it on Android. The generator screen depends on the template version; after applying section 3, compare your result with the Hello captures below.

Concepts in this section:

  • Toolchain: runtime/SDK, package manager, emulator or physical device
  • Project generators (create-expo-app, flutter create)
  • Health-check commands (expo-doctor, flutter doctor)

Visual guide. Follow the toolchain from setup to a visible result on the device.

React Native / Expo: Prepare β€” Node.js + npm β†’ Create and run β€” create-expo-app expo start β†’ Verify β€” Emulator or device expo-doctor; Flutter: Prepare β€” Flutter SDK + Dart β†’ Create and run β€” flutter create flutter run β†’ Verify β€” Emulator or device flutter doctor React Native / Expo: Prepare β€” Node.js + npm β†’ Create and run β€” create-expo-app expo start β†’ Verify β€” Emulator or device expo-doctor; Flutter: Prepare β€” Flutter SDK + Dart β†’ Create and run β€” flutter create flutter run β†’ Verify β€” Emulator or device flutter doctor

Checkpoint: launch the starter app, edit a label, and verify the change.

Setting Up React Native with Expo

Expo provides the project template, the development server, the router and the cloud build service (EAS, used in Part 3).

Steps:

  1. Install Node.js (LTS) and npm: Download and install from https://nodejs.org/

  2. Create a new project (no global CLI to install: the old expo-cli / expo init are no longer used):

    npx create-expo-app@latest notes-app
  3. Navigate to the project directory:

    cd notes-app
  4. Start the development server:

    npx expo start

    Then press a (Android emulator), i (iOS simulator, macOS only) or w (browser), or scan the QR code with the Expo Go app on your phone.

  5. Check the project health (optional but useful when something looks wrong):

    npx expo-doctor

Setting Up Flutter

Steps:

  1. Download the Flutter SDK: Visit https://docs.flutter.dev/get-started/install and follow the instructions for your operating system (the installer also sets up Android tooling).

  2. Add Flutter to your PATH: Follow the OS-specific instructions from the Flutter website.

  3. Run flutter doctor:

    flutter doctor

    Address any issues identified by the command (Android SDK licenses, Xcode on macOS, …).

  4. Create a new project:

    flutter create notes_app
  5. Navigate to the project directory:

    cd notes_app
  6. Start the app:

    flutter run

    (Make sure you have an emulator running or a device connected, or use flutter run -d chrome.)

3. Project Structure and Fundamentals

Expected result. A centered greeting confirms that the entry screen is running.

React Native Β· Android Flutter Β· Android
React Native: A centered greeting confirms that the entry screen is running. React Native: A centered greeting confirms that the entry screen is running. Flutter: A centered greeting confirms that the entry screen is running. Flutter: A centered greeting confirms that the entry screen is running.

Concepts in this section:

  • Anatomy of a generated project
  • The component / widget as the basic building block
  • File-based routing in Expo Router: a file in src/app is a screen
  • Entry points: src/app/index.tsx (React Native) and lib/main.dart (Flutter)

Visual guide. Trace the entry path before reading the generated files.

React Native / Expo Router: Project settings β€” package.json app.json tsconfig.json β†’ Route layout β€” src/app/_layout.tsx Wraps the routes. β†’ Home route β€” src/app/index.tsx Maps to /; Flutter: Project settings β€” pubspec.yaml Dependencies and assets. β†’ Dart entry point β€” lib/main.dart main() β†’ runApp() β†’ Widget tree β€” MaterialApp Scaffold β†’ Text React Native / Expo Router: Project settings β€” package.json app.json tsconfig.json β†’ Route layout β€” src/app/_layout.tsx Wraps the routes. β†’ Home route β€” src/app/index.tsx Maps to /; Flutter: Project settings β€” pubspec.yaml Dependencies and assets. β†’ Dart entry point β€” lib/main.dart main() β†’ runApp() β†’ Widget tree β€” MaterialApp Scaffold β†’ Text

Configuration supports the project; the entry path leads to the first screen.

React Native Project Structure

notes-app/
β”œβ”€β”€ src/
β”‚   └── app/            // Screens: each file is a route
β”‚       β”œβ”€β”€ _layout.tsx // Root layout (navigation container)
β”‚       └── index.tsx   // Route "/"
β”œβ”€β”€ assets/             // Static assets like images
β”œβ”€β”€ app.json            // Expo configuration
β”œβ”€β”€ package.json        // Project metadata and dependencies
└── tsconfig.json       // TypeScript configuration

The generated project contains a demo app. Remove it with the provided script, which moves the starter code to a separate folder and leaves a minimal src/app:

npm run reset-project

Then replace the content of src/app/index.tsx:

src/app/index.tsx (React Native):

import { StyleSheet, Text, View } from 'react-native';

export default function Index() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Hello, React Native!</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    fontSize: 24,
    fontWeight: 'bold',
  },
});

Explanation:

  • We import core components from React Native: View (similar to div on the web) and Text
  • StyleSheet.create defines styles (similar to CSS but in JavaScript objects, with camelCase names)
  • The default export of a file in src/app is the screen displayed for that route
  • The file is TypeScript (.tsx): TypeScript is the default in Expo templates

Flutter Project Structure

notes_app/
β”œβ”€β”€ lib/            // Source code
β”‚   └── main.dart   // Main entry point
β”œβ”€β”€ pubspec.yaml    // Project configuration and dependencies
β”œβ”€β”€ android/        // Android-specific code
β”œβ”€β”€ ios/            // iOS-specific code
β”œβ”€β”€ web/            // Web-specific code
└── test/           // Test files

Replace the content of lib/main.dart:

lib/main.dart (Flutter):

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text(
            'Hello, Flutter!',
            style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
          ),
        ),
      ),
    );
  }
}

Explanation:

  • We import material.dart which provides Material Design widgets
  • main() is the entry point that runs our app
  • MyApp is a StatelessWidget: it has no internal state and just describes the UI in build
  • const constructors let Flutter reuse widgets instead of rebuilding them
  • super.key is the modern way to forward the key parameter to the parent class
  • MaterialApp is the root widget providing the Material Design look and feel
  • Scaffold provides the basic page structure, Center centers its child, Text displays text

4. UI Components and Styling

Expected result. A blue square with rounded corners, a shadow and centered white text.

React Native Β· Android Flutter Β· Android
React Native: A blue square with rounded corners, a shadow and centered white text. React Native: A blue square with rounded corners, a shadow and centered white text. Flutter: A blue square with rounded corners, a shadow and centered white text. Flutter: A blue square with rounded corners, a shadow and centered white text.

Concepts in this section:

  • Core building blocks: containers, text, images, inputs, scrollable lists
  • Styling: StyleSheet objects (React Native) vs. properties/decorations on widgets (Flutter)
  • Layout: Flexbox (React Native) vs. Row / Column / Expanded (Flutter)

Visual guide. Match each region of this schematic screen to a component name.

An annotated notes screen connects its container, text and list, input field and action to React Native and Flutter components. An annotated notes screen connects its container, text and list, input field and action to React Native and Flutter components.

The form is shown inside the screen only to label its parts; the code opens it in a dialog.

React Native Components and Styling

React Native provides a set of core components that map to native UI elements:

  • View: A container (like div on the web)
  • Text: For displaying text (all text must be inside it)
  • Image: For displaying images
  • TextInput: For text input
  • ScrollView: For scrollable content
  • FlatList: For efficient rendering of long lists
  • Pressable: For touchable elements (it replaces the older TouchableOpacity)

Styling Example:

import { StyleSheet, Text, View } from 'react-native';

export default function StyleDemo() {
  return (
    <View style={styles.container}>
      <View style={styles.box}>
        <Text style={styles.text}>Styled Component</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#f5f5f5',
  },
  box: {
    width: 200,
    height: 200,
    backgroundColor: '#3498db',
    borderRadius: 10,
    alignItems: 'center',
    justifyContent: 'center',
    // Cross-platform shadow (works on iOS, Android and web)
    boxShadow: '0 2px 4px rgba(0, 0, 0, 0.25)',
  },
  text: {
    color: 'white',
    fontSize: 18,
    fontWeight: 'bold',
  },
});

Explanation:

  • Styles are similar to CSS but use camelCase (backgroundColor instead of background-color)
  • flex: 1 makes the container take up all available space
  • alignItems and justifyContent control the positioning of children
  • boxShadow is now supported by the New Architecture, so you no longer need the shadow* properties for iOS plus elevation for Android

Flutter Widgets and Styling

Flutter uses widgets for everything in the UI:

  • Container: A box that can have size, color, padding and decoration
  • Text: For displaying text
  • Image: For displaying images
  • TextField: For text input
  • SingleChildScrollView: Similar to ScrollView
  • ListView: Similar to FlatList (ListView.builder renders lazily)
  • InkWell / GestureDetector: For detecting taps and gestures

Styling Example:

import 'package:flutter/material.dart';

class StyleDemo extends StatelessWidget {
  const StyleDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFFF5F5F5),
      body: Center(
        child: Container(
          width: 200,
          height: 200,
          decoration: BoxDecoration(
            color: const Color(0xFF3498DB),
            borderRadius: BorderRadius.circular(10),
            boxShadow: [
              BoxShadow(
                // withValues(alpha:) replaces the deprecated withOpacity()
                color: Colors.black.withValues(alpha: 0.25),
                blurRadius: 4,
                offset: const Offset(0, 2),
              ),
            ],
          ),
          child: const Center(
            child: Text(
              'Styled Component',
              style: TextStyle(
                color: Colors.white,
                fontSize: 18,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Explanation:

  • Container is a box model similar to View in React Native
  • Styling is applied directly on widgets or through the decoration property
  • BoxDecoration provides background color, border radius, shadows…
  • Flutter has no separate stylesheet: the style is part of the widget tree
  • Color.withValues(alpha: ...) replaces withOpacity, which is deprecated

Layout in React Native and Flutter

Expected result. The header and footer keep their height while Content fills the remaining space.

React Native Β· Android Flutter Β· Android
React Native: The header and footer keep their height while Content fills the remaining space. React Native: The header and footer keep their height while Content fills the remaining space. Flutter: The header and footer keep their height while Content fills the remaining space. Flutter: The header and footer keep their height while Content fills the remaining space.

Visual guide. First identify the main axis, then find the child that receives the remaining space.

A vertical column has fixed header and footer and expanding content. Its main axis is vertical. A horizontal row places A, B and C along a horizontal main axis; the cross axis is perpendicular. A vertical column has fixed header and footer and expanding content. Its main axis is vertical. A horizontal row places A, B and C along a horizontal main axis; the cross axis is perpendicular.

Header and footer keep their size while the content area grows.

React Native Layout

React Native uses Flexbox for layout. The main axis is vertical by default (flexDirection: 'column'):

import { StyleSheet, Text, View } from 'react-native';

export default function LayoutDemo() {
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerText}>Header</Text>
      </View>
      <View style={styles.content}>
        <Text style={styles.contentText}>Content</Text>
      </View>
      <View style={styles.footer}>
        <Text style={styles.footerText}>Footer</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'column',
  },
  header: {
    height: 80,
    backgroundColor: '#3498db',
    justifyContent: 'center',
    alignItems: 'center',
  },
  headerText: {
    color: 'white',
    fontSize: 20,
    fontWeight: 'bold',
  },
  content: {
    flex: 1,
    backgroundColor: '#ecf0f1',
    justifyContent: 'center',
    alignItems: 'center',
  },
  contentText: {
    fontSize: 18,
  },
  footer: {
    height: 60,
    backgroundColor: '#2c3e50',
    justifyContent: 'center',
    alignItems: 'center',
  },
  footerText: {
    color: 'white',
    fontSize: 16,
  },
});

Explanation:

  • flex: 1 makes the container take all available space
  • flexDirection: 'column' stacks children vertically (this is the default)
  • The header and footer have fixed heights
  • The content area grows to fill the remaining space with flex: 1

Flutter Layout

Flutter offers dedicated layout widgets instead of a single flexbox model:

import 'package:flutter/material.dart';

class LayoutDemo extends StatelessWidget {
  const LayoutDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Container(
            height: 80,
            color: const Color(0xFF3498DB),
            alignment: Alignment.center,
            child: const Text(
              'Header',
              style: TextStyle(
                color: Colors.white,
                fontSize: 20,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          Expanded(
            child: Container(
              color: const Color(0xFFECF0F1),
              alignment: Alignment.center,
              child: const Text('Content', style: TextStyle(fontSize: 18)),
            ),
          ),
          Container(
            height: 60,
            color: const Color(0xFF2C3E50),
            alignment: Alignment.center,
            child: const Text(
              'Footer',
              style: TextStyle(color: Colors.white, fontSize: 16),
            ),
          ),
        ],
      ),
    );
  }
}

Explanation:

  • Column arranges children vertically (Row does it horizontally)
  • Expanded makes its child take all the remaining space (similar to flex: 1)
  • Container with alignment centers its child without an extra Center widget
  • Column and Row also accept spacing: to add a gap between children (similar to gap in React Native)

5. Building the Notes App

Expected result. Work through sections 5.1–5.4 to reach the local list shown in section 1. No database or account is involved yet.

Concepts in this section:

  • Routing: describing the screens of the app and moving between them
  • Local state: data owned by a screen that triggers a re-render when it changes (useState / setState)
  • Lists: rendering collections efficiently (FlatList / ListView.builder) with stable keys
  • Modal input: collecting data in a dialog and returning it to the caller
  • Refactoring: splitting a big screen into small, reusable components/widgets

Our Notes app will have a home screen and a notes screen where the user can create, edit and delete notes. For now notes live in memory only: they disappear when the app restarts (we fix that in Part 2).

5.1 Setting up Navigation

Expected result. After implementing Home and Notes in sections 5.2–5.3, tapping Open my notes changes the visible screen from the Home capture to the Notes capture. Flutter uses context.go here, so it does not add the same back stack as the React Native Link.

Visual guide. Read the stack from left to right and identify which screen is visible after each action.

Expo Router: Link / push, then Back: Home β€” Stack: [Home] β†’ Open notes β€” Stack: [Home, Notes] β†’ Back β€” Stack: [Home]; Flutter: context.go in this tutorial: Home β€” Location: / β†’ go(’/notes’) β€” Stack: [Notes] β†’ go(’/’) β€” Stack: [Home]; Flutter alternative: push / pop: push(’/notes’) β€” Adds Notes above Home. β†’ pop() β€” Removes Notes to reveal Home. Expo Router: Link / push, then Back: Home β€” Stack: [Home] β†’ Open notes β€” Stack: [Home, Notes] β†’ Back β€” Stack: [Home]; Flutter: context.go in this tutorial: Home β€” Location: / β†’ go(’/notes’) β€” Stack: [Notes] β†’ go(’/’) β€” Stack: [Home]; Flutter alternative: push / pop: push(’/notes’) β€” Adds Notes above Home. β†’ pop() β€” Removes Notes to reveal Home.

Navigation state determines the visible screen; the examples below provide the exact API calls.

React Native Navigation with Expo Router

Expo Router is built on top of React Navigation. You do not install navigation packages by hand: they are part of the template. A route is simply a file.

Create the following structure:

notes-app/
└── src/
    β”œβ”€β”€ app/
    β”‚   β”œβ”€β”€ _layout.tsx   // Navigation stack
    β”‚   β”œβ”€β”€ index.tsx     // "/"        -> Home
    β”‚   └── notes.tsx     // "/notes"   -> Notes
    β”œβ”€β”€ components/
    β”‚   β”œβ”€β”€ NoteItem.tsx
    β”‚   └── NoteInputModal.tsx
    └── types.ts
// src/app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Notes App' }} />
      <Stack.Screen name="notes" options={{ title: 'My Notes' }} />
    </Stack>
  );
}

Explanation:

  • _layout.tsx wraps all screens of the folder: here it declares a stack (screens pushed on top of each other with a back button)
  • name matches the file name without extension
  • options configures the header of each screen
  • Navigating is done with <Link href="/notes"> or router.push('/notes')

Flutter Navigation with go_router

go_router is the package recommended by the Flutter team for declarative routing (it also works with deep links and the browser URL on web). Add it:

flutter pub add go_router

Create the following structure:

notes_app/
└── lib/
    β”œβ”€β”€ main.dart
    β”œβ”€β”€ models/
    β”‚   └── note.dart
    β”œβ”€β”€ screens/
    β”‚   β”œβ”€β”€ home_screen.dart
    β”‚   └── notes_screen.dart
    └── widgets/
        β”œβ”€β”€ note_item.dart
        └── note_input_dialog.dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

import 'screens/home_screen.dart';
import 'screens/notes_screen.dart';

void main() => runApp(const NotesApp());

final _router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
    GoRoute(path: '/notes', builder: (context, state) => const NotesScreen()),
  ],
);

class NotesApp extends StatelessWidget {
  const NotesApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Notes App',
      theme: ThemeData(colorSchemeSeed: const Color(0xFF208AEF)),
      darkTheme: ThemeData(
        colorSchemeSeed: const Color(0xFF208AEF),
        brightness: Brightness.dark,
      ),
      routerConfig: _router,
    );
  }
}

Explanation:

  • GoRouter holds the list of routes; each GoRoute maps a path to a widget
  • MaterialApp.router plugs the router into the app
  • colorSchemeSeed generates a complete, accessible Material 3 color scheme from a single color
  • Providing darkTheme makes the app follow the system dark mode automatically
  • Navigating is done with context.go('/notes') (replace the stack) or context.push('/notes') (add to the stack)

5.2 Home Screen

Expected result. The Home screen offers one action: Open my notes.

React Native Β· Android Flutter Β· Android
React Native: The Home screen offers one action: Open my notes. React Native: The Home screen offers one action: Open my notes. Flutter: The Home screen offers one action: Open my notes. Flutter: The Home screen offers one action: Open my notes.

Visual guide. Use the navigation diagram in section 5.1: the Home button opens the Notes destination.

React Native Home Screen

// src/app/index.tsx
import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Notes App</Text>
      <Text style={styles.subtitle}>React Native + Expo Router</Text>

      <Link href="/notes" style={styles.link}>
        Open my notes β†’
      </Link>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    gap: 12,
    backgroundColor: '#fff',
  },
  title: { fontSize: 32, fontWeight: 'bold' },
  subtitle: { fontSize: 16, color: '#60646c' },
  link: {
    marginTop: 16,
    padding: 12,
    fontSize: 18,
    color: '#208AEF',
  },
});

Explanation:

  • Link is the declarative way to navigate; it renders a Text, so it accepts text styles
  • gap adds space between the children of a flex container

Flutter Home Screen

// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;

    return Scaffold(
      appBar: AppBar(title: const Text('Notes App')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          spacing: 12,
          children: [
            Text('Notes App', style: textTheme.headlineLarge),
            const Text('Flutter + go_router'),
            const SizedBox(height: 16),
            FilledButton(
              onPressed: () => context.go('/notes'),
              child: const Text('Open my notes β†’'),
            ),
          ],
        ),
      ),
    );
  }
}

Explanation:

  • Theme.of(context).textTheme gives access to the Material typography so text follows the theme
  • MainAxisSize.min makes the Column as small as its content (so it is centered)
  • FilledButton is the Material 3 primary button
  • context.go comes from go_router

5.3 Notes Screen

Expected result. The list starts with Welcome and Shopping.

React Native Β· Android Flutter Β· Android
React Native: The list starts with Welcome and Shopping. React Native: The list starts with Welcome and Shopping. Flutter: The list starts with Welcome and Shopping. Flutter: The list starts with Welcome and Shopping.

Enter a title and content, then save the new note.

React Native Β· Android Flutter Β· Android
React Native: Enter a title and content, then save the new note. React Native: Enter a title and content, then save the new note. Flutter: Enter a title and content, then save the new note. Flutter: Enter a title and content, then save the new note.

Workshop appears after saving. Tap a note to edit it; the trash action removes it.

React Native Β· Android Flutter Β· Android
React Native: Workshop appears after saving. Tap a note to edit it; the trash action removes it. React Native: Workshop appears after saving. Tap a note to edit it; the trash action removes it. Flutter: Workshop appears after saving. Tap a note to edit it; the trash action removes it. Flutter: Workshop appears after saving. Tap a note to edit it; the trash action removes it.

Additional real captures: React Native edit, Flutter edit, React Native empty list, Flutter empty list.

Visual guide. Follow one action through the state update, then check how ids survive list changes.

One update: User action β€” Create, edit or delete a note. β†’ State changes β€” Apply the change to the notes list. β†’ UI updates β€” Render the current list again.; Stable identity after deleting note A: Before β€” A: Welcome B: Shopping β†’ Delete A β€” Remove by id, not by screen position. β†’ After β€” B: Shopping B keeps its identity. One update: User action β€” Create, edit or delete a note. β†’ State changes β€” Apply the change to the notes list. β†’ UI updates β€” Render the current list again.; Stable identity after deleting note A: Before β€” A: Welcome B: Shopping β†’ Delete A β€” Remove by id, not by screen position. β†’ After β€” B: Shopping B keeps its identity.

The list is local in Part 1; an id identifies a note independently of its position.

Visual guide. Compare the create and edit paths; cancellation must leave the list unchanged.

Create: New note β€” Start with empty fields. β†’ Edit draft β€” Validate the title. β†’ Save β€” Return / emit a NoteDraft.; Edit: Existing note β€” Start with current values. β†’ Edit draft β€” Keep the note id outside the draft. β†’ Save β€” Caller updates the note with that id.; Cancel: Dismiss β€” Close the form. β†’ No saved draft β€” Do not change the notes list. Create: New note β€” Start with empty fields. β†’ Edit draft β€” Validate the title. β†’ Save β€” Return / emit a NoteDraft.; Edit: Existing note β€” Start with current values. β†’ Edit draft β€” Keep the note id outside the draft. β†’ Save β€” Caller updates the note with that id.; Cancel: Dismiss β€” Close the form. β†’ No saved draft β€” Do not change the notes list.

Conceptual form flow: the explicit NoteDraft contract is introduced by the refactoring in section 5.4.

Notes are described by a small data type, then the screen keeps an array of notes in its state.

React Native Notes Screen

// src/types.ts
export type Note = {
  id: string;
  title: string;
  content: string;
};
// src/app/notes.tsx
import { useState } from 'react';
import { Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

import type { Note } from '../types';

export default function NotesScreen() {
  const [notes, setNotes] = useState<Note[]>([
    { id: '1', title: 'Welcome', content: 'Tap + to add a note.' },
    { id: '2', title: 'Shopping', content: 'Milk, bread, coffee.' },
  ]);
  const [modalVisible, setModalVisible] = useState(false);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const openNew = () => {
    setEditingId(null);
    setTitle('');
    setContent('');
    setModalVisible(true);
  };

  const openEdit = (note: Note) => {
    setEditingId(note.id);
    setTitle(note.title);
    setContent(note.content);
    setModalVisible(true);
  };

  const save = () => {
    if (!title.trim()) {
      Alert.alert('Title required', 'Please enter a title.');
      return;
    }
    if (editingId) {
      // Never mutate state: create a new array
      setNotes(notes.map((n) => (n.id === editingId ? { ...n, title, content } : n)));
    } else {
      setNotes([{ id: Date.now().toString(), title, content }, ...notes]);
    }
    setModalVisible(false);
  };

  const remove = (id: string) => setNotes(notes.filter((n) => n.id !== id));

  return (
    <View style={styles.container}>
      <FlatList
        data={notes}
        keyExtractor={(note) => note.id}
        contentContainerStyle={styles.list}
        ListEmptyComponent={<Text style={styles.empty}>No notes yet.</Text>}
        renderItem={({ item }) => (
          <View style={styles.card}>
            <Pressable style={styles.cardBody} onPress={() => openEdit(item)}>
              <Text style={styles.cardTitle}>{item.title}</Text>
              <Text numberOfLines={2}>{item.content}</Text>
            </Pressable>
            <Pressable onPress={() => remove(item.id)} hitSlop={8}>
              <Text>πŸ—‘οΈ</Text>
            </Pressable>
          </View>
        )}
      />

      <Pressable style={styles.fab} onPress={openNew}>
        <Text style={styles.fabText}>+</Text>
      </Pressable>

      <Modal visible={modalVisible} transparent animationType="fade" onRequestClose={() => setModalVisible(false)}>
        <View style={styles.backdrop}>
          <View style={styles.dialog}>
            <Text style={styles.dialogTitle}>{editingId ? 'Edit note' : 'New note'}</Text>
            <TextInput style={styles.input} placeholder="Title" value={title} onChangeText={setTitle} />
            <TextInput
              style={[styles.input, styles.multiline]}
              placeholder="Content"
              value={content}
              onChangeText={setContent}
              multiline
            />
            <View style={styles.actions}>
              <Pressable onPress={() => setModalVisible(false)}>
                <Text style={styles.action}>Cancel</Text>
              </Pressable>
              <Pressable onPress={save}>
                <Text style={styles.action}>Save</Text>
              </Pressable>
            </View>
          </View>
        </View>
      </Modal>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  list: { padding: 16, gap: 12 },
  empty: { textAlign: 'center', color: '#60646c' },
  card: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 16,
    borderRadius: 12,
    gap: 12,
    backgroundColor: '#f0f0f3',
  },
  cardBody: { flex: 1, gap: 4 },
  cardTitle: { fontSize: 16, fontWeight: 'bold' },
  fab: {
    position: 'absolute',
    right: 20,
    bottom: 28,
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#208AEF',
    alignItems: 'center',
    justifyContent: 'center',
  },
  fabText: { color: '#fff', fontSize: 28, lineHeight: 32 },
  backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 },
  dialog: { backgroundColor: '#fff', borderRadius: 16, padding: 20, gap: 12 },
  dialogTitle: { fontSize: 20, fontWeight: 'bold' },
  input: { backgroundColor: '#f0f0f3', borderRadius: 8, padding: 12, fontSize: 16 },
  multiline: { minHeight: 100, textAlignVertical: 'top' },
  actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 24 },
  action: { color: '#208AEF', fontSize: 16 },
});

Explanation:

  • useState<Note[]> keeps the list in the component state; calling the setter triggers a re-render
  • State is immutable in React: we create a new array with map, filter or the spread operator, instead of using push or modifying an item
  • FlatList renders only the rows that are visible. keyExtractor gives each row a stable key so React can update only what changed
  • ListEmptyComponent is displayed when data is empty
  • Modal displays the form above the screen; editingId tells whether we are creating (null) or editing a note
  • Alert.alert shows a native alert for validation errors
  • This screen works but is long: we will split it in section 5.4

Flutter Notes Screen

// lib/models/note.dart
/// Immutable data class: to change a note, create a copy.
class Note {
  const Note({required this.id, required this.title, required this.content});

  final String id;
  final String title;
  final String content;

  Note copyWith({String? title, String? content}) =>
      Note(id: id, title: title ?? this.title, content: content ?? this.content);
}
// lib/screens/notes_screen.dart
import 'package:flutter/material.dart';

import '../models/note.dart';

class NotesScreen extends StatefulWidget {
  const NotesScreen({super.key});

  @override
  State<NotesScreen> createState() => _NotesScreenState();
}

class _NotesScreenState extends State<NotesScreen> {
  final List<Note> _notes = [
    const Note(id: '1', title: 'Welcome', content: 'Tap + to add a note.'),
    const Note(id: '2', title: 'Shopping', content: 'Milk, bread, coffee.'),
  ];

  Future<void> _showNoteDialog({Note? note}) async {
    final titleController = TextEditingController(text: note?.title);
    final contentController = TextEditingController(text: note?.content);

    final saved = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(note == null ? 'New note' : 'Edit note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          spacing: 12,
          children: [
            TextField(
              controller: titleController,
              decoration: const InputDecoration(labelText: 'Title'),
            ),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 4,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Save'),
          ),
        ],
      ),
    );

    final title = titleController.text.trim();
    final content = contentController.text.trim();
    // NOTE: these controllers are never disposed here. Disposing them right
    // after the dialog closes would crash, because the closing animation still
    // uses them. We fix this properly in section 5.4.

    if (saved != true || title.isEmpty) return;

    setState(() {
      if (note == null) {
        _notes.insert(
          0,
          Note(id: DateTime.now().microsecondsSinceEpoch.toString(), title: title, content: content),
        );
      } else {
        final index = _notes.indexWhere((n) => n.id == note.id);
        _notes[index] = note.copyWith(title: title, content: content);
      }
    });
  }

  void _deleteNote(String id) {
    setState(() => _notes.removeWhere((n) => n.id == id));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Notes')),
      body: _notes.isEmpty
          ? const Center(child: Text('No notes yet.'))
          : ListView.builder(
              padding: const EdgeInsets.all(8),
              itemCount: _notes.length,
              itemBuilder: (context, index) {
                final note = _notes[index];
                return Card(
                  key: ValueKey(note.id),
                  child: ListTile(
                    title: Text(note.title),
                    subtitle: Text(note.content, maxLines: 2, overflow: TextOverflow.ellipsis),
                    onTap: () => _showNoteDialog(note: note),
                    trailing: IconButton(
                      tooltip: 'Delete ${note.title}',
                      icon: const Icon(Icons.delete_outline),
                      onPressed: () => _deleteNote(note.id),
                    ),
                  ),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showNoteDialog(),
        tooltip: 'Add note',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Explanation:

  • A screen with changing data is a StatefulWidget: the widget is immutable, the mutable data lives in its State class
  • setState(() {...}) tells Flutter that the data changed and the UI must be rebuilt
  • ListView.builder builds the rows lazily, only the ones that are visible
  • ValueKey plays the same role as keyExtractor in React Native
  • showDialog<bool> returns a Future: await pauses the function until the user closes the dialog and gives us its result
  • TextEditingController objects hold resources and should be disposed: this first version leaks them, which is one more reason to refactor (section 5.4)
  • The data class is immutable (final fields + copyWith), which matches the immutable-state idea of React

5.4 Component Refactoring

Expected result. The extracted components keep the same visible result as section 5.3. The captures use this refactored version. Verify that creating, editing, cancelling and deleting still work; restarting the local app restores its initial sample notes.

Concepts in this section:

  • Single responsibility: one component/widget, one job
  • Props and callbacks (React) vs. constructor parameters and callbacks (Flutter): data flows down, events flow up
  • Reusable dialog that returns a result instead of knowing about the list

Visual guide. Read solid arrows downward for data and dashed arrows upward for user intent.

The Notes screen gives each NoteItem its note and gives the form initial values. Edit, delete, save and cancel events return to the screen. The screen owns the note list in these examples. The Notes screen gives each NoteItem its note and gives the form initial values. Edit, delete, save and cancel events return to the screen. The screen owns the note list in these examples.

The screen coordinates reusable pieces through input values and callbacks or dialog results.

React Native Component Refactoring

Extract the card into NoteItem:

// src/components/NoteItem.tsx
import { Pressable, StyleSheet, Text, View } from 'react-native';

import type { Note } from '../types';

type Props = {
  note: Note;
  onEdit: (note: Note) => void;
  onDelete: (id: string) => void;
};

export function NoteItem({ note, onEdit, onDelete }: Props) {
  return (
    <View style={styles.card}>
      <Pressable style={styles.body} onPress={() => onEdit(note)} accessibilityRole="button">
        <Text style={styles.title}>{note.title}</Text>
        <Text numberOfLines={2}>{note.content}</Text>
      </Pressable>
      <Pressable
        onPress={() => onDelete(note.id)}
        accessibilityRole="button"
        accessibilityLabel={`Delete ${note.title}`}
        hitSlop={8}>
        <Text>πŸ—‘οΈ</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 16,
    borderRadius: 12,
    gap: 12,
    backgroundColor: '#f0f0f3',
  },
  body: { flex: 1, gap: 4 },
  title: { fontSize: 16, fontWeight: 'bold' },
});

Extract the form into NoteInputModal:

// src/components/NoteInputModal.tsx
import { useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

import type { Note } from '../types';

export type NoteDraft = Pick<Note, 'title' | 'content'>;

type Props = {
  visible: boolean;
  initial?: NoteDraft; // undefined = creating a new note
  onSave: (draft: NoteDraft) => void;
  onCancel: () => void;
};

export function NoteInputModal({ visible, initial, onSave, onCancel }: Props) {
  return (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
      {/* A different key re-creates the form, which resets its fields */}
      <Form key={initial ? initial.title + initial.content : 'new'} initial={initial} onSave={onSave} onCancel={onCancel} />
    </Modal>
  );
}

function Form({ initial, onSave, onCancel }: Omit<Props, 'visible'>) {
  const [title, setTitle] = useState(initial?.title ?? '');
  const [content, setContent] = useState(initial?.content ?? '');
  const canSave = title.trim().length > 0;

  return (
    <View style={styles.backdrop}>
      <View style={styles.dialog}>
        <Text style={styles.heading}>{initial ? 'Edit note' : 'New note'}</Text>
        <TextInput style={styles.input} placeholder="Title" value={title} onChangeText={setTitle} />
        <TextInput
          style={[styles.input, styles.multiline]}
          placeholder="Content"
          value={content}
          onChangeText={setContent}
          multiline
        />
        <View style={styles.actions}>
          <Pressable onPress={onCancel}>
            <Text style={styles.action}>Cancel</Text>
          </Pressable>
          <Pressable
            disabled={!canSave}
            onPress={() => onSave({ title: title.trim(), content: content.trim() })}
            style={{ opacity: canSave ? 1 : 0.4 }}>
            <Text style={styles.action}>Save</Text>
          </Pressable>
        </View>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 },
  dialog: { backgroundColor: '#fff', borderRadius: 16, padding: 20, gap: 12 },
  heading: { fontSize: 20, fontWeight: 'bold' },
  input: { backgroundColor: '#f0f0f3', borderRadius: 8, padding: 12, fontSize: 16 },
  multiline: { minHeight: 100, textAlignVertical: 'top' },
  actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 24 },
  action: { color: '#208AEF', fontSize: 16 },
});

The screen becomes much shorter:

// src/app/notes.tsx
import { useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';

import { NoteInputModal, type NoteDraft } from '../components/NoteInputModal';
import { NoteItem } from '../components/NoteItem';
import type { Note } from '../types';

export default function NotesScreen() {
  const [notes, setNotes] = useState<Note[]>([
    { id: '1', title: 'Welcome', content: 'Tap + to add a note.' },
    { id: '2', title: 'Shopping', content: 'Milk, bread, coffee.' },
  ]);
  // null = dialog closed, 'new' = creating, a Note = editing it
  const [editing, setEditing] = useState<Note | 'new' | null>(null);

  const save = (draft: NoteDraft) => {
    if (editing === 'new') {
      setNotes([{ id: Date.now().toString(), ...draft }, ...notes]);
    } else if (editing) {
      setNotes(notes.map((n) => (n.id === editing.id ? { ...n, ...draft } : n)));
    }
    setEditing(null);
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={notes}
        keyExtractor={(note) => note.id}
        contentContainerStyle={styles.list}
        ListEmptyComponent={<Text style={styles.empty}>No notes yet.</Text>}
        renderItem={({ item }) => (
          <NoteItem
            note={item}
            onEdit={setEditing}
            onDelete={(id) => setNotes(notes.filter((n) => n.id !== id))}
          />
        )}
      />

      <Pressable
        style={styles.fab}
        onPress={() => setEditing('new')}
        accessibilityRole="button"
        accessibilityLabel="Add note">
        <Text style={styles.fabText}>+</Text>
      </Pressable>

      <NoteInputModal
        visible={editing !== null}
        initial={editing && editing !== 'new' ? editing : undefined}
        onSave={save}
        onCancel={() => setEditing(null)}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  list: { padding: 16, gap: 12 },
  empty: { textAlign: 'center', color: '#60646c' },
  fab: {
    position: 'absolute',
    right: 20,
    bottom: 28,
    width: 56,
    height: 56,
    borderRadius: 28,
    backgroundColor: '#208AEF',
    alignItems: 'center',
    justifyContent: 'center',
  },
  fabText: { color: '#fff', fontSize: 28, lineHeight: 32 },
});

Explanation:

  • NoteItem receives the note and two callbacks as props: it does not know where the data comes from
  • NoteInputModal only edits a draft and calls onSave(draft): the screen decides what to do with it
  • One editing state replaces several booleans/ids and makes impossible states impossible
  • accessibilityRole and accessibilityLabel make the app usable with a screen reader

Flutter Component Refactoring

Extract the card into NoteItem:

// lib/widgets/note_item.dart
import 'package:flutter/material.dart';

import '../models/note.dart';

class NoteItem extends StatelessWidget {
  const NoteItem({
    super.key,
    required this.note,
    required this.onEdit,
    required this.onDelete,
  });

  final Note note;
  final VoidCallback onEdit;
  final VoidCallback onDelete;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(note.title),
        subtitle: Text(note.content, maxLines: 2, overflow: TextOverflow.ellipsis),
        onTap: onEdit,
        trailing: IconButton(
          tooltip: 'Delete ${note.title}',
          icon: const Icon(Icons.delete_outline),
          onPressed: onDelete,
        ),
      ),
    );
  }
}

Add a NoteDraft type to the model file (a Dart record: a lightweight immutable tuple with named fields):

// lib/models/note.dart (add at the end)

/// Title and content typed by the user, before an id is assigned.
typedef NoteDraft = ({String title, String content});

Extract the dialog. It is a function that returns the result of the dialog:

// lib/widgets/note_input_dialog.dart
import 'package:flutter/material.dart';

import '../models/note.dart';

/// Shows the dialog and returns the draft, or null if the user cancelled.
Future<NoteDraft?> showNoteInputDialog(BuildContext context, {Note? initial}) {
  return showDialog<NoteDraft>(
    context: context,
    builder: (_) => _NoteInputDialog(initial: initial),
  );
}

class _NoteInputDialog extends StatefulWidget {
  const _NoteInputDialog({this.initial});

  final Note? initial;

  @override
  State<_NoteInputDialog> createState() => _NoteInputDialogState();
}

class _NoteInputDialogState extends State<_NoteInputDialog> {
  late final _title = TextEditingController(text: widget.initial?.title);
  late final _content = TextEditingController(text: widget.initial?.content);

  @override
  void dispose() {
    _title.dispose();
    _content.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text(widget.initial == null ? 'New note' : 'Edit note'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        spacing: 12,
        children: [
          TextField(
            controller: _title,
            decoration: const InputDecoration(labelText: 'Title'),
            onChanged: (_) => setState(() {}), // re-evaluate the Save button
          ),
          TextField(
            controller: _content,
            decoration: const InputDecoration(labelText: 'Content'),
            maxLines: 4,
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        FilledButton(
          // null disables the button
          onPressed: _title.text.trim().isEmpty
              ? null
              : () => Navigator.pop(
                    context,
                    (title: _title.text.trim(), content: _content.text.trim()),
                  ),
          child: const Text('Save'),
        ),
      ],
    );
  }
}

The screen becomes:

// lib/screens/notes_screen.dart
import 'package:flutter/material.dart';

import '../models/note.dart';
import '../widgets/note_input_dialog.dart';
import '../widgets/note_item.dart';

class NotesScreen extends StatefulWidget {
  const NotesScreen({super.key});

  @override
  State<NotesScreen> createState() => _NotesScreenState();
}

class _NotesScreenState extends State<NotesScreen> {
  final List<Note> _notes = [
    const Note(id: '1', title: 'Welcome', content: 'Tap + to add a note.'),
    const Note(id: '2', title: 'Shopping', content: 'Milk, bread, coffee.'),
  ];

  Future<void> _add() async {
    final draft = await showNoteInputDialog(context);
    if (draft == null) return;
    setState(() {
      _notes.insert(
        0,
        Note(
          id: DateTime.now().microsecondsSinceEpoch.toString(),
          title: draft.title,
          content: draft.content,
        ),
      );
    });
  }

  Future<void> _edit(Note note) async {
    final draft = await showNoteInputDialog(context, initial: note);
    if (draft == null) return;
    setState(() {
      final index = _notes.indexWhere((n) => n.id == note.id);
      _notes[index] = note.copyWith(title: draft.title, content: draft.content);
    });
  }

  void _delete(String id) => setState(() => _notes.removeWhere((n) => n.id == id));

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Notes')),
      body: _notes.isEmpty
          ? const Center(child: Text('No notes yet.'))
          : ListView.builder(
              padding: const EdgeInsets.all(8),
              itemCount: _notes.length,
              itemBuilder: (context, index) {
                final note = _notes[index];
                return NoteItem(
                  key: ValueKey(note.id),
                  note: note,
                  onEdit: () => _edit(note),
                  onDelete: () => _delete(note.id),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: _add,
        tooltip: 'Add note',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Explanation:

  • NoteItem is a StatelessWidget: it receives data and callbacks through its constructor
  • VoidCallback is the type of a function without parameters and without result (() => void)
  • The dialog widget owns its TextEditingControllers and disposes them in dispose(), so the screen stays clean
  • The dialog returns a NoteDraft?: null means “cancelled”, which is easier to handle than a boolean plus separate controllers
  • The screen only coordinates: show dialog, update the list, rebuild

Good practice: after an await, if you use the BuildContext again, check if (!mounted) return; first (the screen may have been closed meanwhile). In this screen we only call setState after the await, which is safe because the data is plain values.

React Native vs Flutter Comparison

Expected result. Compare the two actual results in sections 5.2 and 5.3: they implement the same interactions using the native controls and styling of each framework.

Visual guide. Review the rendering overview and the data-flow diagram while comparing the framework names below.

Concept React Native (Expo) Flutter
Project creation npx create-expo-app@latest flutter create
Language TypeScript Dart
UI building block Component (function returning JSX) Widget (class with a build method)
Layout Flexbox (View + style props) Row, Column, Expanded, Padding…
Styling StyleSheet.create Widget properties and BoxDecoration, themes
Long lists FlatList ListView.builder
Local state useState StatefulWidget + setState
List keys keyExtractor / key ValueKey / key
Navigation Expo Router (files = routes) go_router (GoRoute list)
Modal form Modal + TextInput showDialog + AlertDialog + TextField
Passing data down Props Constructor parameters
Sending events up Callback props (onSave) Callbacks (VoidCallback) or returned values

In Part 2 we replace the in-memory list with a real database.


By Wahid Hamdi